# siplot → silx parity roadmap
Generated from an 11-agent parity sweep of `silx.gui.plot` against the
siplot implementation. Scope (per project decision): **silx.gui.plot +
adjacent silx.gui** (colors, data-adjacent GUI widgets).
**Totals (as-of-sweep):** 397 features — 165 Done, 49 Partial, 183 Missing.
**Re-baselined 2026-06-04 (main @ `d04232a`):** ≈191 Done · 130 open (10 H / 48 M /
34 L) — see [Remaining work (re-baselined 2026-06-04)](#remaining-work-re-baselined-2026-06-04-main--d04232a).
**After Wave 13 (ROI styling, main @ `7495590`, not pushed):** ≈199 Done · 122 open (7 H / 44 M / 33 L) — 8 ROI
rows closed (per-instance color/name/selection, `sigCurrentRoiChanged`, line style/width, manager default-color, fill).
**After Wave 14 (item-click + hover + marker-drag triad, main, not pushed):** ≈206 Done · 115 open (6 H / 42 M / 29 L)
— 7 rows closed: curveClicked (H), curveClicked/imageClicked (L), hover-with-metadata (M), ItemsInteraction picker/
state-machine (M), marker drag-start/end triad (L), markerMoving/markerMoved split (L). `ItemHovered` carries the full
silx `prepareHoverSignal` payload (`selectable` omitted as structurally constant); the marker drag now emits
`MarkerDragStarted`→`MarkerMoved`×N→`MarkerDragFinished` (silx `beginDrag`/`drag`/`endDrag`).
**After Wave 15 (PlotWidget interaction-events + panzoom + draw feedback, main, not pushed):** ≈212 Done · ~109 open
— 6 rows closed: `LimitsChanged` carries the new `{x,y,y2}` ranges (row 61), RoiCreate draw events surface as
`DrawingProgress`/`DrawingFinished` (row 57), axis-constraint adaptive normalization mirroring silx `ViewConstraints`
allow_scaling=True — max-range capped to the position window + a wider-than-window view snaps to it (row 62),
box-zoom and draw previews unified onto one selection-overlay renderer with silx Zoom's `fill="none"` dashed outline
(row 60), the polygon first-point close target box (row 66), and the pencil brush-footprint preview circle (row 65).
Rows 49 / 63 (overlay-only replot, StateMachine base) and their re-baseline duplicates resolved as **N/A** — functional
parity achieved differently (immediate-mode + persistent GPU buffers make redraw always-on and uploads event-driven;
imperative `apply_interaction` dispatch replaces the silx state-machine hierarchy). This closes the enumerated
interaction-events-panzoom open rows.
**Mask drawing tools + colormap autoscale (main, not pushed):** ≈216 Done · ~105 open — 3 H rows closed: on-plot
Rectangle (row 126), Ellipse (row 128), and Polygon (row 127) mask draws; plus the ColormapDialog Stddev3/Percentile
autoscale wired to raw pixels (row 133, ◐→✅). The pure fill primitives (`update_rectangle`/`update_ellipse`/
`update_polygon`) already existed; the gap was the on-plot draw interaction. Wired high-level like the pencil
(reusing `DrawState`/`feed_draw_state`/the extracted `paint_draw_preview`, no new interaction mode or `PlotEvent`):
`MaskTool::draw_mode` is the single source of truth for which tools are shape draws; `MaskToolsWidget::handle_shape_draw`
feeds the draw machine and, on finish, `fill_from_draw` converts data→cells (silx `int()`/`astype(int64)` truncation,
`_plotDrawEvent`) and masks the level; `ImageView::handle_mask_shape_draw` paints the rubber-band preview.
**ROIStatsWidget display (main, not pushed):** ≈217 Done · ~104 open — 1 H row closed: the per-ROI statistics table
(row 111). `RoiStatsWidget` renders one row per ROI (`ROI | N | min | max | mean | sum | integral`) over the active
item; `PlotWidget::feed_roi_stats`/`show_roi_stats_widget` reduce each ROI via the pre-existing
`image_roi_stats`/`curve_roi_stats` and follow the active item + live ROI list. Pure row-building (`roi_stats_rows`)
is headlessly tested (image/curve per-ROI, empty); the active-item feed + render stay GPU-unverified. The
`high_level_roi_stats` example now uses the widget instead of a hand-rolled bbox reduction.
**CurvesROIWidget integration (main, not pushed):** ≈225 Done · ~96 open — the last open H row closed: per-curve ROI
stats (row 117). `CurvesRoiWidget` shows one row per x-span ROI over the active curve
(`ROI | From | To | Raw Counts | Net Counts | Raw Area | Net Area`); `PlotWidget::feed_curves_roi_stats`/
`show_curves_roi_widget` reduce each ROI via the new pure `curve_roi_counts`, faithful to silx
`computeRawAndNetCounts`/`computeRawAndNetArea` (array-order selection — numpy does not sort; linear-endpoint
background for net counts; numpy-trapezoid for area; NaN-y summed as-is, unlike `curve_roi_stats`). Pure core
(`curve_roi_counts`, 7 tests) + row-building (`curve_roi_rows`, 2 tests) are headlessly tested; the active-curve feed
+ egui render stay GPU-unverified. New example `high_level_curves_roi`. No open H rows remain in the primary table.
**CompareImages draggable separator (main, not pushed):** ≈225 Done · ~95 open — 1 M re-baseline row closed (row 220,
◐→✅); the bottom-inventory mode rows (1274/1275) were already ✅, so the headline Done count is unchanged — this closes
the on-plot draggable-separator residual. The split position is now a blue on-plot draggable line marker (silx `__vline`
for `HalfHalf`, `__hline` for `SplitHorizontal`) placed at `split * extent`, recreated when the orientation changes;
dragging it folds the data position back into `split` (silx `__separatorMoved`, clamped `[0,1]` per `__separatorConstraint`),
with the slider kept in sync. This required a structural fix: `MarkerSpec` gained `is_draggable`/`constraint` (silx
`addMarker(draggable=, constraint=)`), now carried by `marker_from_spec` and `marker_spec_from_data` — previously no
high-level API could create a draggable marker (the fields lived only on the mirror `Marker`). Verified by drag-simulation
(`tests/compare_separator.rs`, 3 tests: vertical/horizontal separator drag sets `split`, programmatic `set_split`
repositions the line); GPU render is the shared CompareImages shim.
**StackView Profile3D toolbar (main, not pushed):** ≈226 Done · ~94 open — 1 L row closed (rows 233 + 1291, both ◐→✅).
The row-253 stack-profile extraction (`stack_aligned_profile`/`stack_line_profile`) is now driven by the full silx
`Profile3DToolBar`: new `StackView::show_profile3d_toolbar` (h-line / v-line / line ROI buttons + a 1D/2D `profileType`
toggle, silx `_DefaultImageStackProfileRoiMixIn.setProfileType`), `handle_profile_drag` (a primary drag on the image,
mirroring `ImageView`'s profile drag), and `show_profile` which routes per dimension: 1D → the current frame's profile
in a `ProfileWindow` curve (silx profileType `"1D"`); 2D → the profile stacked over every frame as a colormapped image
in the new `StackProfileWindow` (silx profileType `"2D"`, `core.ImageProfileData(colormap=item.getColormap())` — the
window reuses the stack's own colormap). `StackProfileWindow` mirrors `ProfileWindow`'s viewport/placement but backs a
`Plot2D` image (frame index × profile position, Y not inverted). New public types `StackProfileDimension` +
`StackProfileWindow`; StackView reserves plot ids `id+1`/`id+2` for the two windows. Verified end-to-end by drag-simulation
(`tests/stack_profile_window.rs`, 5 tests: a horizontal drag opens the 2D image window with the hand-verified stacked
row profile, a 1D drag opens the curve window, the dimension toggle hides the inactive window, a line over the volume
yields a profile, and 2D correctly returns nothing in flat-frames mode). 2D requires `set_volume` (a flat-frames stack
has no 3D source — documented, not stubbed).
**Ruler measurement tool wired (main, not pushed):** ≈227 Done · ~93 open — 1 M row closed (row 1279, ◐→✅; the
"Actions, Toolbars, Tool Buttons" section now has 0 ◐). The `RulerToolButton` (checkable button + tested `distance_text`,
silx `buildDistanceText`) is now driven end-to-end on the base `PlotWidget`: `set_ruler_active(true)` enters a line-ROI
draw (`RoiCreate(RoiDrawKind::Line)`, saving the prior interaction mode); `show_inner` adopts each completed line draw
(`response.roi_created`) as *the* ruler line and labels it with its measured length via `distance_text`, recomputing on
edit — a new measurement replaces the previous line (the old is removed; the fresh line is always the last ROI, so its
index is robust under the removal's index shift), and disarming removes the line + restores the saved mode (silx
single-measurement `_RulerROI` / deselect). Faithful to silx, which has no live ROI format-function, so the host
recomputes the label (per the `RulerToolButton` doc-comment). Verified by drag-simulation (`tests/ruler.rs`, 4 tests:
arm enters the line draw, a drag draws a line whose name equals `distance_text` of its own geometry, a second drag
replaces rather than accumulates, disarm removes the line and restores Zoom). siplot: fmt + clippy + nextest (1335
tests) all pass.
**AlphaSlider item bindings + image-alpha retention (main, not pushed):** ≈228 Done · ~92 open — 1 L row closed (row
1186, ◐→✅; the "Colormap, ColorBar, and AlphaSlider" section now has 0 ◐). The two silx item-binding subclasses are now
wired against the plot model: `ActiveImageAlphaSlider` (silx `getItem()=getActiveImage()`) and `NamedItemAlphaSlider`
(silx `NamedImageAlphaSlider`, an image by legend) seed from the bound image's alpha, disable when no such image exists
(silx "getItem() → None"), and write each slider change back through new `PlotWidget` APIs `image_alpha`/`set_image_alpha`
+ `active_image_alpha`/`set_active_image_alpha`/`active_image_handle`. The enabling structural fix (fix-the-family): the
image's global opacity is now retained in `RetainedItemData::Image` (captured by the single owner `image_spec_retained_data`)
and read back into the rebuilt `ImageSpec` on every re-upload path — level edit, raw-pixel autoscale, median filter — which
previously dropped it to the `ImageSpec::scalar` default of `1.0`. Verified by 9 tests (`tests/alpha_slider_binding.rs`):
alpha survives all three re-upload paths, the per-item alpha API round-trips and clamps, the slider seeds from / disables
without an image, and a kittest slider drag lowers the live image's alpha (image alpha == slider value). Documented model
limitation: silx's curve/scatter *named* bindings stay deferred — siplot retains a re-applicable per-item alpha only for
scalar images, so those sliders correctly disable rather than drive nothing. siplot: fmt + clippy + nextest (1344 tests)
all pass.
**Image re-upload preserves interpolation/aggregation too (main, not pushed):** ≈228 Done · ~92 open (no count change —
rows 1157/1160 were already ✅) — structural follow-up closing the rest of the field-drop family the alpha fix exposed.
`RetainedItemData::Image` now also retains `interpolation` + `aggregation` + `aggregation_block`, and a single owner
`ImageDisplayAttrs::apply` is the one place that restores all carried-over display attributes (geometry, alpha,
interpolation, aggregation) onto the rebuilt `ImageSpec::scalar` — so the generic re-upload paths (level edit, autoscale,
median filter, the new `set_image_alpha`) no longer silently reset a non-default `Linear`/`Max` image to the
`scalar()` defaults, and a future re-upload path that calls `apply` cannot drop them. (ImageView was unaffected — it
rebuilds via its own `image_view_image_spec` from separately-held fields.) Verified by a headless round-trip unit test
(`image_display_attrs_round_trip_preserves_every_reupload_field`) asserting every field survives capture→restore. siplot:
fmt + clippy + nextest (1345 tests) all pass.
**PositionInfo live snapping wired (main, not pushed):** ≈229 Done · ~91 open — 1 M row closed (row 315, ◐→✅; the
"stats-profile-fit-positioninfo" section now has no open snapping row). The two pure cores (`snapping_candidates` +
`snap_to_nearest`) are now driven against a live plot by `PlotWidget::snap_cursor(cursor, mode) -> Option<Snap>`: it builds
the `SnapItem` list from `item_records` (kind via a `PlotItemKind→SnapItemKind` map, `is_item_visible`, `curve_data.symbol`,
active-item), runs the candidate selector, projects each candidate's retained `Curve{x,y}` vertices through the cached
axis-aware `data_to_pixel` (a y2 curve snaps in its own axis), and returns the nearest point within `SNAP_THRESHOLD_DIST`.
Curves, histograms, AND scatters all participate — a base-widget scatter is a symbol-only curve-kind item that retains its
points, so `SCATTER` mode snaps scatter points while `CURVE` mode snaps curve/histogram vertices (kind-filtered by the
mode). `PositionInfo::ui_snapped` reddens the readout in the no-snap state (silx :200) and uses the normal style on a snap
(:288). kittest-verified (`tests/position_info_snap.rs`, 5 tests): on-vertex snap, off-vertex no-snap, DISABLED/SCATTER-mode
filtering, scatter-mode snap (+ CURVE-mode rejecting a scatter), and uncached-transform no-snap. siplot: fmt + clippy
(`-p siplot --all-targets`) + nextest (1350 tests) all pass.
**Mask transparency slider UI verified (main, not pushed):** ≈230 Done · ~90 open — 1 S row closed (row 280, ◐→✅). The
mask overlay transparency slider in `MaskToolsWidget::show_toolbar` was already routed through the tested
`set_transparency`/`mask_overlay_lut` cores but its rendered UI was GPU-unverified; now a kittest test
(`tests/mask_transparency_slider.rs`) renders the toolbar (pure egui — the overlay LUT upload is a separate GPU step),
locates the "Transparency" slider by accesskit role+label (the label text and the slider share the label, so role
`SpinButton` disambiguates), drags it to the low end, and asserts the wiring identity — the widget's `transparency()`
(a new getter, symmetric with the existing `set_transparency`) equals the slider's displayed value and drops near the 0.0
minimum. siplot: fmt + clippy (`-p siplot --all-targets`) + nextest (1351 tests) all pass.
**ComplexImageView amplitude-range dialog UI verified (main, not pushed):** ≈231 Done · ~89 open — 1 M row closed (row
299, ◐→✅). The `show_amplitude_range_controls` dialog (autoscale checkbox + "Displayed Max." + delta drag-values, silx
`_AmplitudeRangeDialog`) had its composite math + `set/get_amplitude_range_info` accessors tested but the rendered UI was
GPU-unverified; now a kittest+wgpu test (`tests/complex_amplitude_controls.rs`, 3 tests) renders the controls AND the image
each frame (so a range change dispatches the GPU image rebuild through `show`): unchecking "autoscale" seeds the displayed
max from the data max amplitude (5.0) and re-checking clears it to `None` (silx `_autoscaleCheckBoxToggled`); dragging the
now-enabled "Displayed Max." drag-value raises it above the seed; dragging the delta drag-value raises the log-decade count
above the 2.0 default — every change observed through `amplitude_range_info()`. (Drag-values share the slider's accesskit
`SpinButton` role and carry no label, so they are targeted by role + tree order: max first, delta last.) siplot: fmt +
clippy (`-p siplot --all-targets`) + nextest (1354 tests) all pass.
**Image per-pixel alpha map (main, not pushed):** ≈232 Done · ~88 open — 1 M row closed (row 254, ☐→✅; duplicate frozen
row 1198 ☐→✅). Per-pixel alpha (silx `ImageData.get/setAlphaData`) ported end-to-end on the scalar GPU path, modeled on
the nan_color shader work. Core: `ImageData` gained `alpha_map: Option<Vec<f32>>` (`with_alpha_map`, length-validated
`w*h`); `ImageSpec`/`ImagePixelsSpec` carry it (`with_alpha_map`, scalar-only — RGBA ignores it as in silx);
`image_data_from_spec` reduces it by the same blocks (mean coverage) when the data is aggregated so it matches the
displayed grid; the `ImageData→ImageSpec` bridge (`image_spec_from_data`) carries it so `add_image_data` keeps it. GPU:
`ImageParams` gained a `has_alpha_map` flag (+ explicit pad → 144 B, std140-tested); the scalar bind-group layout a
binding-5 unfilterable-float alpha texture; `ImagePipeline` a shared 1×1 dummy bound when an image has no map; each tile
uploads its `extract_subgrid` slice to its own R32Float texture. `image.wgsl` hand-rolls the bilinear/nearest alpha
sample (R32Float is unfilterable) under the image's interpolation and multiplies it into the global alpha on both the
normal and nan_color paths. GPU-verified by a headless wgpu readback (`tests/image_alpha_map.rs`): a
left-opaque/right-transparent map over a uniform teal image leaves the left half teal and the right half background,
while an unmasked control render shows teal on both halves (colorbar disabled so its viridis ramp can't pollute the
count). Re-upload preservation of the map follows in the next entry.
siplot: fmt + clippy (`-p siplot --all-targets`) + nextest (1355 tests) all pass.
**Image alpha map survives re-upload (main, not pushed):** ≈232 Done · ~88 open (no count change — structural follow-up
closing the rest of the field-drop family, exactly as the interpolation/aggregation follow-up did for the global alpha
fix). `RetainedItemData::Image` now also retains the per-pixel `alpha_map` (captured by the single owner
`image_spec_retained_data`), and the single-owner `ImageDisplayAttrs::apply` — which already restored
geometry/alpha/interpolation/aggregation onto the rebuilt `ImageSpec::scalar` — now restores the alpha map too. So the
generic re-upload paths (level edit, autoscale, median filter, `set_image_alpha`) no longer silently drop a per-pixel
alpha overlay to the `scalar()` default of `None`, and a future re-upload path that calls `apply` cannot drop it. The
attrs bundle owns the map by value (no longer `Copy`) and `apply` borrows it for the rebuilt spec's lifetime; the four
re-upload call sites are unchanged. Verified by the round-trip unit test (now asserting the alpha map survives
capture→restore) and a headless wgpu re-upload readback (`tests/image_alpha_map.rs`: a colormap level edit keeps the
masked right half transparent). siplot: fmt + clippy (`-p siplot --all-targets`) + nextest (1356 tests) all pass.
**Mask file save/load dialog (main, not pushed):** ≈232 Done · ~88 open (no count change — rows 326/327/1328/1329 stay ◐
because TIFF/HDF5/msk remain external-crate-bound, but their listed "no file dialog" gap is removed). `MaskToolsWidget`
gained `save_mask_dialog`/`load_mask_dialog` (silx `_saveMask`/`_loadMask`): a native rfd picker with npy/edf filters that
dispatches to the existing hand-written codecs by file extension. The extension→format mapping is one single owner
`mask_format_for_ext`, with `resolve_mask_save_format` (missing extension → npy default, unsupported rejected — faithful
to silx `save`) and `resolve_mask_load_format` (unknown extension rejected — faithful to silx `load` which raises on an
unknown `os.path.splitext` extension). The picker is an untested native shim; the dispatch resolvers are unit-tested (2
tests: save defaults npy + rejects tif/h5, load requires a known extension, both case-insensitive). siplot: fmt + clippy
(`-p siplot --all-targets`) + nextest (1358 tests) all pass.
**Round line joins + caps (main, not pushed):** ≈234 Done · ~86 open — 2 L rows closed (1258 joins + 1259 caps, both
☐→✅; the WgpuBackend §13 summary now lists no line-join/cap gap). The polyline was drawn as independent butt-capped
segment quads, so a sharp turn left a wedge gap on the outer side of the join and the two ends were flat. New
`GpuCurve::draw_caps` stamps an antialiased disc of the line width at every polyline vertex (`shaders/linecaps.wgsl`,
reusing the marker bind-group layout + the line's exact 1-px feather AA, sharing the points + per-vertex-color buffers
so a coloured join takes its own vertex's color): the union with the segment quads is a round-joined, round-capped
stroke — silx's pygfx `LineMaterial` default, verified against `~/codes/pygfx/.../wgsl/line.wgsl` (which exposes no
cap/join config and renders rounded). Solid lines only — a disc at every vertex would fill a dash's gaps, so dashed
lines keep butt ends — and zero-width hairlines are skipped. No user-facing butt/round/square *selector* (silx/pygfx
have none on curves; a selector would be a non-silx invention). Honest limitation: pygfx's single-pass unified coverage
has no geometry overlap, whereas the disc double-composites against the segment ends only for *translucent* lines
(opaque lines — the dominant case — are exact, and the bare segments already overlapped at inner corners). GPU readback
verified via the cached `data_to_pixel` (`tests/curve_caps_joins.rs`, 2 tests): a round cap extends ~half the width
beyond the endpoint and its bbox corner is background (rounded, not square); a round join fills the outer wedge of a
right-angle turn (a point beyond both butt segments is painted) without a miter spike past the width. siplot: fmt +
clippy (`-p siplot --all-targets`) + nextest (1360 tests) all pass.
**Mask TIFF save/load (main, not pushed):** ≈234 Done · ~86 open (no count change — rows 353/354/1355/1356 stay ◐
because HDF5 + fit2d `.msk` remain, but the TIFF gap in all four is removed). First use of the user's relaxed
new-dependency allowance: added the image-rs `tiff` crate and ported mask TIFF as a single-page grayscale `uint8` image
(silx `MaskToolsWidget` save/load TIFF branch, via fabio/`tifffile`). New single-owner codecs
`render::save::encode_mask_tiff`/`decode_mask_tiff` (decode rejects multi-channel and non-`uint8` samples — a mask is 2D
single-channel `uint8`), with `MaskToolsWidget::write_tiff`/`read_tiff` + `save_tiff`/`load_tiff` +
`save_mask_tiff`/`load_mask_tiff` mirroring the npy/edf wrappers and routing through the shared `apply_loaded_mask`
crop/pad+commit owner. `MaskFileFormat` gained `Tiff`; the single-owner `mask_format_for_ext` maps `tif`/`tiff`
(case-insensitive), so the save/load dialogs gained a TIFF filter + dispatch arm. Verified: codec round-trip +
multi-channel rejection (`render::save` tests), widget read/write round-trip + larger-mask crop (`mask_tools` tests),
and the dispatch tests flipped (`.tif`/`.tiff` now resolve to `Tiff`; `.h5`/`.msk` are the remaining rejects). siplot:
fmt + clippy (`-p siplot --all-targets`) + nextest (1364 tests) all pass.
**Mask HDF5 save/load (main, not pushed):** ≈234 Done · ~86 open (no count change — rows 366/367/1368/1369 stay ◐
because fit2d `.msk` remains, but the HDF5 gap in all four is removed, including the "HDF5 dataset selection" sub-gap).
Used the user-chosen pure-Rust `rust-hdf5` crate — a plain build-everywhere dependency (no native libhdf5, no optional
feature, no env vars). New single-owner codecs `render::save::write_mask_hdf5`/`read_mask_hdf5`/`read_mask_hdf5_auto`/
`list_mask_datasets_hdf5`: write a 2D `uint8` dataset (`H5File::create` → `new_dataset::<u8>().shape([h,w])` →
`write_raw` → `close`), read it back (`open` → `dataset` → `ndims()==2` guard → `read_raw`, length-checked), and
enumerate every 2D dataset (`dataset_names()` filtered by `ndims()`, sorted). HDF5 is a random-access container, not a
byte stream, so — unlike npy/edf/tiff — these take a path, not `impl Write`. `MaskToolsWidget` gained `save_h5`/`load_h5`
(load auto-selects the first 2D dataset) + `load_h5_dataset`/`mask_datasets` (the dataset-SELECTION mechanism: enumerate
+ load by an explicit name) + `save_mask_h5`/`load_mask_h5`, all routing through the shared `apply_loaded_mask`
crop/pad+commit owner. `MaskFileFormat::H5`; the single-owner `mask_format_for_ext` maps the six silx
`NEXUS_HDF5_EXT` (`.h5 .nx5 .nxs .hdf .hdf5 .cxi`); both dialogs gained an HDF5 filter + dispatch arm. One divergence
remained at the time of this entry: the interactive Qt "Select a 2D dataset" picker UI is not ported (the underlying
selection mechanism — enumerate + load-by-name — is). (The "fresh standalone file vs silx append" divergence noted here
was later closed — see the "Mask HDF5 save append" entry below.) Verified: widget round-trip + larger-mask crop +
enumerate-then-load-by-name (`mask_tools` tests),
dispatch tests flipped (`.h5`/`.nxs`/`.hdf5` resolve to `H5`; `.msk` the remaining reject). siplot: fmt + clippy
(`-p siplot --all-targets`) + nextest (1367 tests); plus the full-workspace gate (Cargo.toml dep change): clippy
(`--workspace --all-targets`) + nextest (1777 tests) all pass.
**Mask fit2d `.msk` save/load (main, not pushed):** ≈234 Done · ~86 open (no count change — rows 366/367/1368/1369 stay
◐, but the mask file save/load now cover ALL FIVE silx formats — npy/edf/tif/h5/msk; at the time of this entry the
remaining sub-gaps were the interactive HDF5 dataset-picker UI and the HDF5 save append "a" mode — the latter has since
been closed, see the "Mask HDF5 save append" entry below). The fabio reference WAS found locally
(`~/mamba/envs/silx-pygfx/.../fabio/fit2dmaskimage.py`, same env as the silx reference), so this is a faithful port, not
a guess. New single-owner codecs `render::save::encode_mask_msk`/`decode_mask_msk` implement the Andy Hammersley fit2d
format: a 1024-byte header (`MASK` magic at bytes 0/4/8/12, `dim1`=width @16 + `dim2`=height @20 as little-endian `u32`,
byte 24 = 1) then one bit per pixel, LSB-first, packed into rows of `((width+31)/32)` 32-bit ints. fit2d masks are
**binary** — every non-zero level collapses to a set bit on write and reads back as 1 (exactly fabio's `data != 0`), a
documented format limitation. `MaskToolsWidget` gained `write_msk`/`read_msk` + `save_msk`/`load_msk` +
`save_mask_msk`/`load_mask_msk` (a byte-stream codec like edf/tiff, routed through the shared `apply_loaded_mask`
owner); `MaskFileFormat::Msk`; `mask_format_for_ext` maps `msk`; both dialogs gained a Fit2D filter + dispatch arm.
Verified BYTE-FOR-BYTE against fabio: a reference `.msk` generated by `fabio.Fit2dMaskImage` on a known 2×5 mask
(`1a00000004000000` body) is reproduced exactly by `encode_mask_msk` (`mask_msk_matches_fabio_reference_bytes`), plus
odd-width bit-padding + bad-magic/short-header rejection (`render::save` tests) and widget binary round-trip +
larger-mask crop (`mask_tools` tests). The dispatch tests flipped (`.msk` now resolves to `Msk`; only a genuinely
unknown extension rejects). siplot: fmt + clippy (`-p siplot --all-targets`) + nextest (1372 tests) all pass.
**Mask HDF5 save append "a" mode (main, not pushed):** ≈234 Done · ~86 open (no count change — rows 366/367/1368/1369
stay ◐, but one of the two HDF5 sub-gaps is now closed: only the interactive dataset-picker UI remains). `write_mask_hdf5`
was made faithful to silx's `h5py.File(filename, "a")` open mode: an existing file is opened for append
(`H5File::open_rw`, preserving its other datasets) instead of being truncated (`H5File::create` was the prior
divergence), and an existing dataset at the path is deleted first (`delete_dataset`, silx's overwrite branch) before the
mask is recreated; a missing file is still created. This was verified EMPIRICALLY (rust-hdf5's write-mode
`dataset_names()` does list pre-existing datasets after `open_rw`, and `delete_dataset` works there) with two new tests:
`h5_save_append_preserves_existing_dataset` (seed an unrelated 2D `keep` dataset, save the mask, assert both survive and
read back intact) and `h5_resave_overwrites_existing_mask_dataset` (a second save replaces the mask, not errors on the
duplicate name). siplot: fmt + clippy (`-p siplot --all-targets`) + nextest (1374 tests) all pass.
**Mask HDF5 "Select a 2D dataset" picker UI (main, not pushed):** ≈238 Done · ~82 open — 4 rows closed ◐→✅ (366/367 +
duplicates 1368/1369: mask file save/load). This closes the last mask-file-IO sub-gap. silx's `MaskToolsWidget`
`loadAction`/`saveAction` (the Load.../Save... toolbar buttons) and the `DatasetDialog` "Select a 2D dataset" picker are
now ported. `show_toolbar` gained Load.../Save... buttons that open the native rfd file dialog; for HDF5 the dataset
choice then runs through an egui modal (silx `_selectDataset`): a load (`begin_h5_load`) lists the file's 2D datasets to
pick (silx `LoadMode`), a save (`begin_h5_save`) names the target dataset — default `mask`, existing datasets offered as
overwrite shortcuts (silx `SaveMode`) — driving `load_h5_dataset`/the new `save_h5_dataset`. The picker state
(`H5DatasetPicker`) is a pure tested seam: `for_load` seeds the first dataset, `for_save` seeds `mask`, `resolved_path`
applies silx's `lstrip("/")` + empty-rejection rule. The modal itself is GPU-verified with egui_kittest
(`tests/mask_h5_picker.rs`: a load picks the non-default dataset and reads it; a save writes the current mask to the
named dataset). Deviation from silx: the secondary "overwrite existing dataset?" confirmation QMessageBox is not
reproduced (the save modal lists the existing datasets and OK overwrites directly). siplot: fmt + clippy
(`-p siplot --all-targets`) + nextest (1379 tests) + doctests all pass; sidm/adl2sidm rebuild (public-API add).
**Rustdoc intra-doc link cleanup (main, not pushed):** no parity-row change (doc-comment-only). Closed the entire
pre-existing backlog of rustdoc intra-doc-link diagnostics across 26 files (68 sites): 51 `private_intra_doc_links`
(public docs hyperlinking private internals → unlinked to plain backticked code, the correct fix — public docs must not
link private items), 26 `broken_intra_doc_links` (wrong paths → retargeted to the real `crate::`-rooted item, e.g.
`Plot2D::show_profile_toolbar`→`PlotWidget::show_profile_toolbar`, `crate::widget::SceneWidget::pick`→
`crate::SceneWidget::pick`, `StatsWidget`/`FitWidget`/`ColorBarWidget`/`Transform`/`PositionInfo`/`SaveTarget::from_path`
→ their `crate::` re-exports; concept-only `FitTheory`/glob `mode_for_*`/trait `Mat4::mul` unlinked), plus 1
`redundant_explicit_links`. No `#[allow]` was used and no code/signatures/visibility changed — only `///`/`//!`
comments. Verified: `RUSTDOCFLAGS="-D rustdoc::broken_intra_doc_links -D rustdoc::private_intra_doc_links" cargo doc
--no-deps -p siplot` is clean (0 errors, 0 warnings); siplot fmt + clippy (`-p siplot --all-targets`) + nextest (1379
tests) + doctests all pass.
**Section-header count reconciliation (main, not pushed):** no parity-population change (doc-consistency fix). Three
per-section summary headers had drifted from their tables because earlier row-flip commits updated the row glyph but not
the header count: "Items: Image, Marker, Shape, Complex" `11✅ 0◐ 1☐`→`12✅ 0◐ 0☐` (per-pixel alpha row flipped ☐→✅ by
`302fc8b`; its prose still listed per-pixel alpha as a "Remaining gap" — now corrected, both it and concave fill are
closed), "Backend Render" `36✅ 2◐ 2☐`→`38✅ 2◐ 0☐` (line joins+caps flipped ☐→✅ by `9a41c2d`; the `✅` count stays a
prose-summary, only `2☐`→`0☐`), and "Mask tools (image + scatter)" `25✅ 2◐ 0☐`→`27✅ 0◐ 0☐` (mask save/load flipped
◐→✅ by `b878a82`). Verified every section header's ◐/☐ now matches its table (Backend Render's `✅` is a
prose-summary count by design). Dated "After Wave N" progress-log totals above are point-in-time records, left as-is.
**Print-preview page editor (main, not pushed):** 1 L row closed (rows 523/1582, ☐→✅). `PrintPreview`
(`widget/print_preview.rs`): a detachable page editor mirroring silx `PrintPreviewDialog` +
`PrintPreviewToolButton` — a white page on a gray backdrop holding movable, corner-resizable image items, with
the silx default placement (`_getViewBox`: 0.1 offset, 0.9 width, keep-aspect) and the silx keep-ratio resize
math (`_GraphicsResizeRectItem`: pick the smaller growth ratio for uniform scale, 40px bottom-right handle).
`add_image(rgba,w,h,title,comment,pos)` takes the same in-memory RGBA that `render_plot_rgba` (now `pub`, the
single owner of plot→pixels readback) produces for `save_graph`, so a live plot drops onto the page with no
re-snapshot. The pure geometry/composite core is fully tested (`resize_item_rect`, `default_placement`,
`compose_page` nearest-neighbor src-over blit) plus a headless render-path test. **Deviation:** silx `_print`
submits the composed scene to a `QPrinter` device; siplot has no Qt print device, so the page is composited
to a PNG via `compose_page`/`take_page_png` and written through a Save Page dialog (mirroring silx's own
print-to-file path); live system-printer submission stays with the existing `PrintDialog`. Verified: full
`--workspace` fmt + clippy (`-D warnings`) + nextest (1801 tests) + doctests all pass.
**CompareImages AUTO/SIFT alignment (main, not pushed):** 2 L rows closed (1547 ◐→✅, 1548 ☐→✅; re-baseline row
424 ☐→✅). New pure module `core::sift_align` ports silx `CompareImages.__createSiftData`→`LinearAlign` onto the
pure-Rust `lowe-sift` crate (a new, MIT, rust-version-1.92, `default-features=false` dependency — Lowe's SIFT
detector/descriptor/ratio matcher + `estimate_affine_from_pairs`, the same 6-param affine least squares as silx
`matching_correction`): `sift_auto_align` pads both images to the common `max×max` grid, detects SIFT keypoints,
matches descriptors (Lowe ratio 0.8), fits the affine A→B, and bilinearly warps B onto A's frame (zero border);
`decompose_affine` mirrors `__toAffineTransformation` (`rot=atan2(-b,a)`, `sx=sign(a)·√(a²+b²)`,
`sy=sign(d)·√(c²+d²)`). Wired into `CompareImages` as `CompareAlignment::Auto` (toolbar "auto" button): `show`
runs the registration on the dirty path and caches it; `build_composite` places A padded + the warped B;
`raw_pixel_data` maps the cursor to B through the estimated affine; too few matches fall back to ORIGIN (silx
`__setDefaultAlignmentMode`). **Deviation:** coordinates stay in image (x,y) end-to-end rather than silx's
OpenCL (y,x) kernel swap (same affine, natural axis order); silx's GPU OpenCL SIFT is replaced by the CPU
`lowe-sift` (same Lowe 2004 algorithm). 10 headless core tests + 2 kittest AUTO end-to-end (register a known
shift, featureless fallback). The affine read-out (`getTransformation`, row 1559) and keypoint overlay (row
1561) build on this and land next. Verified: full `--workspace` fmt + clippy (`-D warnings`) + nextest +
doctests all pass (lowe-sift is a new public dependency → workspace-scoped gate).
**CompareImages affine transform read-out (main, not pushed):** 1 L row closed (1576 ☐→✅). `CompareImages::transformation() -> Option<AffineTransformation>` (silx `getTransformation`) exposes the decomposed `(tx,ty,sx,sy,rotation)` from the cached SIFT result — `Some` only while `CompareAlignment::Auto` registration holds, `None` for origin/center/stretch (silx couples it to the SIFT path). The status bar (`show_status_bar`, row 1579) now renders silx's `Align: Translation+Scale+Rotation` summary (`align_summary`: notable-component test at atol 0.01, "No big changes"/"No changes" fallbacks via numpy-isclose defaults) with the per-component px/deg breakdown on hover (`align_tooltip`); silx's `Translation x`-gated-on-`ty` transcription slip is corrected to gate on `tx`. 2 headless summary/tooltip tests + a kittest asserting the (+3,+2) recovery and `None` after a featureless fallback. Composite-views header 29✅ 1◐ 4☐ → 30✅ 1◐ 3☐. Verified: siplot fmt + clippy (`-p siplot --all-targets`) + nextest all pass.
**CompareImages keypoint overlay toggle (main, not pushed):** 1 L row closed (1580 ☐→✅). `CompareImages::set_keypoints_visible`/`keypoints_visible` (silx `setKeypointsVisible`/`getKeypointsVisible`, off by default) + `matched_keypoints()` (silx `__matching_keypoints`). `sync_keypoint_overlay` (silx `__updateKeyPoints`) draws the matched keypoints as a `Symbol::Cross` point scatter at their image-A common-grid coords on top of the composite, rebuilt with the composite and cleared when hidden or non-AUTO; toolbar "kp" checkbox wired. silx colours keypoints by scale through a dedicated colormap — siplot uses one conspicuous colour (a single `add_scatter_with_symbol` colour). kittest toggles the overlay on/off and asserts the matched-keypoint set survives (scatter pixels GPU-unverified). Composite-views header 30✅ 1◐ 3☐ → 31✅ 1◐ 2☐ (remaining: ImageStack URL lazy-load + prefetch). Verified: siplot fmt + clippy (`-p siplot --all-targets`) + nextest all pass.
**ImageStack lazy/threaded loading (main, not pushed):** 1 L row closed (1571, ☐→✅). `ImageStack` gains a second mode beside the in-memory `set_frames`: `set_sources(Vec<String>)` + `set_loader(Arc<dyn FrameLoader>)` (silx `setUrls` / `setUrlLoaderClass`). Each slot starts empty — reusing the existing `None`-slot waiting-overlay path (silx `WaitingOverlay`) — and its frame is loaded on a spawned background thread through the pluggable `FrameLoader` trait (silx `UrlLoader.run`, `None` on failure), the result sent back over an `mpsc` channel and drained on the next `ui` pass (silx `_urlLoaded`, queued connection) with `ctx.request_repaint()` to wake the UI. A failed slot is terminal — never re-dispatched — matching silx storing the `None` in `_urlData`. The dispatch/dedup/outcome bookkeeping is a pure `LoadSchedule` (NotRequested/InFlight/Loaded/Failed), unit-tested in isolation (4 tests); `tests/image_stack_lazy.rs` (3 kittest) covers the in-flight→fill (gated loader proves the overlay shows while loading), terminal-failure-not-retried, and browse-loads-the-new-slot paths. `FrameLoader` is re-exported from the crate root. Prefetch radius is row 1573; a concrete HDF5 loader is frozen-inventory row 444. Composite-views header 31✅ 1◐ 2☐ → 32✅ 1◐ 1☐. Verified: siplot fmt + clippy (`-p siplot --all-targets`) + nextest + full `--workspace` clippy/nextest/doctests + rustdoc link check all pass.
**ImageStack prefetch queue (main, not pushed):** 1 L row closed (1573, ☐→✅). `ImageStack::set_n_prefetch`/`n_prefetch` (silx `setNPrefetch`/`getNPrefetch`, default 10 = `N_PRELOAD`) set how many neighbours on each side of the current slot to preload. The pure `prefetch_candidates(current, len, n)` computes the load window — current first, then the next `n` slots (nearest first), then the previous `n`, each clamped to `[0, len)` — mirroring silx `setCurrentUrl` loading the current url then `_preFetch(_getNNextUrls(n))` + `_preFetch(_getNPreviousUrls(n))`. `load_candidates` now feeds this window into the same `LoadSchedule`-gated background-dispatch path landed in row 1571, so neighbours preload on their own threads while browsing, deduped against in-flight/loaded slots. 5 pure window tests (middle order, both-ends clamp, zero-radius, empty stack, radius > length) + a kittest (`tests/image_stack_lazy.rs`) asserting current±1 load at radius 1 without navigating, with the out-of-window slots left untouched. Composite-views header 32✅ 1◐ 1☐ → 33✅ 1◐ 0☐ (the section's last ☐ closed; the lone ◐ is row 1572's per-URL add/remove + url-text-in-rows). Verified: siplot fmt + clippy (`-p siplot --all-targets`) + nextest + full `--workspace` clippy/nextest/doctests + rustdoc link check all pass.
**ImageStack HDF5 loader (main, not pushed):** 1 frozen-inventory row closed (ImageStack lazy URL/HDF5 loading, ☐→✅). Concrete `Hdf5FrameLoader` (implements the `FrameLoader` trait from row 1571) reads HDF5 frames on demand: a source string is `"<file>::<dataset>"` for a 2D dataset (one frame) or `"<file>::<dataset>::<index>"` for one slice of a 3D `[N,H,W]` stack — only that frame is read off disk via `read_slice`, which is the lazy-path payoff. New `render::save::read_image_hdf5` / `read_image_hdf5_slice` do the read through the pure-Rust `rust-hdf5` crate (the established HDF5 choice) and convert to `f32`. CAVEAT, documented not masked: `rust-hdf5` 0.2's public dataset API surfaces only the element *byte size*, not the datatype *class*, so the read supports float datasets (4-byte f32 / 8-byte f64→f32) and a same-width integer dataset would be reinterpreted — a genuine limitation of the dependency, surfaced in the function/loader docs and rejected for other element sizes. `Hdf5FrameLoader` is re-exported from the crate root; each `load` opens its own file handle so it is safe to call from several prefetch threads. 8 unit tests (read_image_hdf5 f32 roundtrip / f64 cast / non-2D reject; read_image_hdf5_slice frame values / OOB + non-3D reject; loader 2D / 3D-slice / malformed-source-and-missing-file → None). No header tally change (frozen-baseline table). Verified: siplot fmt + clippy (`-p siplot --all-targets`) + nextest + full `--workspace` clippy/nextest/doctests + rustdoc link check all pass.
**ImageStack URL table: remove + URL-text rows (main, not pushed):** 1 L row closed (1578, ◐→✅) — the Composite-views section's last ◐. In lazy mode the frame table now shows each row's source/URL text (silx `UrlList`) via `row_label` (loaded frames still in-memory show their frame label), and a per-row "✕" button removes that source (silx `UrlList` delete → `sigUrlRemoved`). `ImageStack::remove_source(index)` (silx `removeUrl`) is the single owner of slot removal: it mutates all four parallel lanes — `sources`, the load `schedule`, and the nav's `frames`/`visible` — in lockstep, so `sources.len() == frames.len()` holds by construction, and `FrameNav::remove` keeps the displayed frame where possible (removal before the current shifts its index down with no rebuild; removing the current points at the next frame clamped to the new last, marked dirty; removing after is index-neutral). 6 pure removal tests (before/current+clamp/after/last-remaining/out-of-range + the `LoadSchedule` lane) + a kittest (`tests/image_stack_lazy.rs`: the table renders all three URLs, removing the middle realigns `sources()`/`len()` and the removed URL disappears from the table). Composite-views header 33✅ 1◐ 0☐ → 34✅ 0◐ 0☐ (section fully closed). Verified: siplot fmt + clippy (`-p siplot --all-targets`) + nextest + full `--workspace` clippy/nextest/doctests + rustdoc link check all pass.
**Parity queue exhausted — accepted residuals (main, not pushed):** with the Composite-views section closed (34✅ 0◐ 0☐, row 1578), the actionable silx.gui.plot parity queue is exhausted. Every section is now fully ✅ except two, and their remaining glyphs are accepted residuals, not deferred work — each blocked by a framework/platform constraint that no in-ecosystem port can faithfully close (re-confirming the earlier "truly exhausted" determination at `1a0da5f`, now after the CompareImages SIFT and ImageStack lazy-loading waves landed). The full residual set (6 rows):
- **Backend Render — 38✅ 2◐ 0☐.** (1) addMarker custom font (row 1412): marker labels render in a fixed proportional font — egui `FontId` cannot express silx `QFont` weight/italic without bundling font-variant assets (the marker `bgcolor` half of this row IS done). (2) async GPU stats/histogram (row 1420): pure performance — silx's GPU compute reduction for streaming data is not mirrored; the functional min/max/histogram/autoscale results all exist and are tested on CPU.
- **ROI system — 29✅ 1◐ 0☐.** Mid-gesture `sigInteractiveRoiCreated` (row 1467): siplot's immediate-mode rubber-band has no persistent mid-draw ROI object to signal, so it emits `DrawingProgress` during the gesture (the faithful analogue) and `RoiCreated` = silx `sigInteractiveRoiFinalized` on draw-finish; the rest of the manager-signal set is distinct and tested.
- **Detailed-table residuals** (each summarised under an otherwise-closed section header): colormap matplotlib-dynamic loading (impossible without a Python/matplotlib dep — the 45-entry static `colorous` catalog is the faithful substitute); print native dialog + printer submission (OS-native, blocking, headlessly unverifiable — the `print_graph()` GPU-readback and the print-PREVIEW page editor are both done); OpenGLAction backend selector (N/A — siplot is wgpu-only by design).
These six match established-choice exclusion categories (Python-dep / OS-native-blocking-UI / bundled-font-asset / speculative-perf / immediate-mode-structural-deviation) and are documented in their row cells; no remaining row has a clean, headlessly-testable, in-ecosystem pure core left to port. No code changed in this entry (doc-only end-state record).
**Histogram bin alignment (main, not pushed):** ≈219 Done · ~102 open — 1 M row closed (row 108, ◐→✅). `HistogramAlign`
{Left,Center,Right} + the pure `histogram_edges(positions, align)` mirror silx `items.histogram._computeEdges`
(`Histogram.setData(align=)`): Left treats positions as left edges (append `x[-1]+last_gap`), Right as right edges
(prepend `x[0]-first_gap`), Center right-aligns then shifts each edge left by half its following gap so positions land
at bin centres; a lone position uses silx's unit-gap fallback. Public `PlotWidget::add_histogram_aligned`/`_with_legend`
take N positions + N counts + an alignment. The pure edge derivation is headlessly tested per alignment (incl.
single-position, non-uniform centre, empty, and the edges→step-values composition); the GPU add stays unverified.
**StackView perspective + 3D transposition (main, not pushed):** ≈221 Done · ~100 open — 2 M rows closed (182, 183;
duplicates 1233, 1237). The silx `StackView.py` port (`high_level.rs`, frames as `Vec<Vec<f32>>`) gains volume
browsing: `StackPerspective` {Axis0,Axis1,Axis2}, the pure `stack_frame`/`stack_frame_count` slice a 2D frame out of a
row-major `[d0,d1,d2]` volume per perspective (matching silx `__createTransposedView` transpose (1,0,2)/(2,0,1)), and
`dimension_axis_labels` picks X=width-axis / Y=height-axis labels (silx `__updatePlotLabels`). `StackView::set_volume`
keeps the volume and re-slices on `set_perspective`; `perspective_ui` is the browse-dimension combo. Pure core
headlessly tested (per-perspective slicing, frame counts, display-axis mapping, length/range rejection); the
volume-load + perspective-switch GPU path stays unverified. Default dimension labels only — the `setLabels` API (row
184) is the next commit.
**StackView dimension labels (main, not pushed):** ≈222 Done · ~99 open — 1 M row closed (184; duplicate 1267).
`StackView::set_dimension_labels([&str;3])`/`dimension_labels()` mirror silx `setLabels`/`getLabels`: each volume
dimension gets a label (empty → default `"Dimension N"`, like silx's `label or default`), and `apply_axis_labels`
chooses the X (width-axis) and Y (height-axis) label from these as the perspective rotates. `perspective_ui` names the
browse-dimension combo entries from the same labels. The pure default-label → axis-label mapping is headlessly tested;
the `set_dimension_labels` GPU label-application path stays unverified.
**ScatterView selection-mask accessors (main, not pushed):** ≈223 Done · ~98 open — 1 M row closed (198, ◐→✅;
duplicate 1273). `ScatterView::selection_mask() -> &[u8]` and `set_selection_mask(&[u8]) -> Result<usize, _>` mirror
silx `ScatterView.getSelectionMask`/`setSelectionMask`: per-point u8 levels (0 unmasked, 1..=255 a level). `set`
validates the length equals the current point count (silx raises ValueError on shape mismatch), copies the levels, and
commits to the mask's undo history. The mask is a selection query (not a render overlay), so no re-render is needed.
The accessors are thin wrappers over the already-tested `ScatterMaskWidget`; constructing a `ScatterView` needs a wgpu
`RenderState`, so the wrapper's length-check path itself stays GPU-unverified.
**ScatterView position-info panel (main, not pushed):** ≈224 Done · ~97 open — 1 M row closed (205, ◐→✅; duplicate
1261). `ScatterView::show_position_info(ui, &PlotResponse)` embeds the existing `PositionInfo` widget with the silx
X/Y/Data/Index columns (ScatterView.py:90-101). The pure `scatter_pick_pixels(cursor, points_px, radius)` picks the
nearest scatter point in **pixel** space (so the snap radius is constant on screen at any zoom; ties → highest/top-most
index, silx `_pickScatterData`), and `scatter_position_info(Option<ScatterPick>)` builds the bar: X/Y/value/index snap
to the pick, or X/Y show the bare cursor and Data/Index show "-" when nothing is within `SCATTER_PICK_RADIUS_PX`
(= default marker size). The cursor is tracked from the plot's pointer event (silx `sigMouseMoved`). Pure pick + column
builder headlessly tested (nearest/none/tie, snap/fallback/placeholder); the transform-projection + render path stays
GPU-unverified.
Status legend: ✅ Done · ◐ Partial · ☐ Missing · ✅ N/A (resolved: parity achieved differently). Effort S/M/L. Priority H/M/L.
> This file tracks the port. The per-area tables below are the **as-of-sweep
> baseline**; landed work is recorded in the Progress log so the baseline stays
> a stable reference. Follow-ups that a wave deliberately deferred are listed too.
## Remaining work (re-baselined 2026-06-04, main @ d04232a)
These figures supersede the older **"Remaining work (live, code-verified
2026-06-02)"** view, which was frozen before Waves 10–12 and the ~18 commits that
landed after the Wave-12 progress-log entry (detached windows; Arc/Band/Circle/
Ellipse/Rect handle-drag editing; the `apply_curve_alpha` double-premultiply fix;
the R2.1 mid-drag mode-switch fix; legend icon styling; active-curve axis labels;
right-click zoom menu; vertical-colorbar/rotated-Y-label centering fixes). An
8-slice read-only recon (workflow `wf_b2b09dc2-620`) re-derived **every** status
from the code on `main` rather than trusting the stale status column:
**191 features Done** (counts-based, approximate) vs **130 still open** (Partial +
Missing, deduped; four stale "Missing" interaction rows dropped because their own
evidence shows them implemented — log-aware pan/zoom, float32-safe limits, overflow
protection). The tables below enumerate the **92 explicitly-tracked open rows** with
a priority (10 H / 48 M / 34 L), each traced to a silx ref; the climb to 130 comes
from per-slice tallies that include un-enumerated lower-detail gaps. P = priority,
E = effort. The frozen per-area baseline tables further down are kept only as the
as-of-sweep reference.
### core-axes-ticks
| status | P | E | feature | silx ref | gap |
|---|---|---|---|---|---|
| ✅ Done | M | M | Per-axis timezone support | items/axis.py:278-294 (get/setTimeZone) | Full silx surface (`a61a944`,`0c78553`,`895cee3`): `TimeZone{Utc, FixedOffset{seconds_east}, Named(tz::TimeZoneRef)}` + `TimeZone::named(name)`/`local()`; `dtime_ticks` `_tz` layout (`calc_ticks_tz`/`format_tick(s)_tz`, `from`/`to_epoch_seconds_tz`) over the single instant-aware `offset_at`, `Plot::x_time_zone`/`set_x_time_zone` threaded through chrome. `Utc`=`"UTC"`, `FixedOffset`=`datetime.timezone(timedelta)`, `Named`/`local`=arbitrary `dateutil.tz` zone / `None` via the bundled IANA tz DB (`tzdb`/`tz-rs`, DST-aware). Nuances: `local()` resolves the system zone once at construction (not re-resolved per render); the `to_epoch` local→UTC inversion uses the standard two-step refine (DST fold ≈ default). Tested incl. EST/EDT + a spring-forward window |
| ✅ N/A | L | M | Overlay-only replot optimization | PlotWidget.py:719-730 (`_setDirtyPlot`, overlay vs True) | Resolved W15 — not a gap. silx's `_dirty` defers redraws for a *retained* backend; siplot is immediate-mode with persistent GPU buffers: curve/image data uploads only on add/update (event-driven), the per-frame `prepare` only re-decimates a *changed* view + writes cheap uniforms, `paint` draws from persistent buffers. There is no per-frame full upload to skip; a `dirty()` gate would be speculative + can't skip egui's frame paint. `DirtyState`/`dirty()` remain as a faithful silx-concept port (set, not consumed in production) |
### interaction-events-panzoom
| status | P | E | feature | silx ref | gap |
|---|---|---|---|---|---|
| ✔ Done (W14) | H | M | Specific item-click signals (curveClicked/markerClicked/imageClicked) | PlotInteraction.py:1223-1261, PlotEvents.py:88-173 | `PlotEvent::{CurveClicked,ImageClicked,ItemClicked}` carry handle + position + button via the `pick_topmost`/`click_event_for_pick` owner path |
| ✔ Done (W14) | M | M | Hover event signals with item metadata | PlotInteraction.py:1135-1154, PlotEvents.py:73-85 | `PlotEvent::ItemHovered{handle,kind,label,x,y,xpixel,ypixel,draggable}` mirrors silx `prepareHoverSignal`. silx `selectable` omitted by design: every pickable item is set-active on click in siplot, so the flag is structurally constant `true` (uninformative) |
| ✔ Done (W15) | M | S | DrawingProgress / DrawingFinished event wiring | PlotInteraction.py:529-532, PlotEvents.py:34-55 | `apply_interaction` surfaces the RoiCreate `DrawEvent` on `PlotResponse.draw_event`; `high_level.rs` emits `PlotEvent::DrawingProgress{mode,points}` / `DrawingFinished{mode,params}` (the latter alongside `RoiCreated`) |
| ✔ Done (W14) | M | M | ItemsInteraction state machine (item picking/dragging) | PlotInteraction.py:1115-1350 | The pickers are wired through one owner `pick_topmost`: click → `Curve/Image/ItemClicked` (silx `_handleClick`), bare hover → `ItemHovered` (silx Idle), and a draggable-marker drag runs the `MarkerDragStarted`→`MarkerMoved`→`MarkerDragFinished` lifecycle (silx `beginDrag`/`drag`/`endDrag`) |
| ✔ Done (W14) | L | S | Marker drag-finished vs drag-moving signal split | PlotInteraction.py:1276-1299, 1350 | Split: `MarkerMoved` per frame (silx `markerMoving`) + distinct on-release `MarkerDragFinished` (silx `markerMoved`), bracketed by `MarkerDragStarted` |
| ✅ Done | L | S | Selection-area color/fill-mode styling | PlotInteraction.py:98-141 | W15: box-zoom rubber band and draw preview share one `draw_selection_polygon` renderer honoring `FillMode` (Hatch/Solid/None) + color. Box-zoom now renders silx Zoom's `fill="none"` dashed outline instead of a hardcoded solid rect (`plot_widget.rs`). Public per-mode color setter not exposed (no consumer) |
| ✔ Done (W15) | L | S | Limits-changed event with actual range values | PlotEvents.py:176-184 | `PlotEvent::LimitsChanged{x,y,y2}` now carries the new left-axis `(min,max)` ranges and the optional right axis, built from the post-change snapshot in `push_limits_changed_if` |
| ✅ Done | L | M | Axis constraints (min/maxXRange, min/maxYRange) | panzoom.py:222-366 | `AxisConstraints::apply` mirrors silx `ViewConstraints` (allow_scaling=True): max-range capped to the position window (`update` sanity), and a view wider than the window snaps to it (`normalize` adaptive expansion) — W15, `core/plot.rs` |
| ✅ N/A | L | M | StateMachine base (ClickOrDrag hierarchy) | Interaction.py:87-198 | Resolved — not a gap. silx's `StateMachine`/`ClickOrDrag` class hierarchy is an implementation structure, not a user-facing feature; siplot achieves the same interaction behavior with `DrawState` + the imperative `apply_interaction` dispatch (functional parity achieved differently) |
| ✔ Done (W14) | L | S | Marker drag-start/end callbacks (clicked/moving/moved triad) | PlotInteraction.py:1223-1350 | Full lifecycle: `MarkerDragStarted` (begin) → `MarkerMoved`×N (moving) → `MarkerDragFinished` (on-release moved); marker click arrives via `ItemClicked` from the unified picker |
| ✅ Done | L | S | Pencil preview circle during freehand draw | PlotInteraction.py:955-1110 (`updatePencilShape`) | W15: `ImageView::draw_brush_preview` paints the silx pencil footprint — an unfilled circle of radius `brush_size/2` (13 pts, `pencil_preview_circle`) at the cursor in MaskDraw mode, idle + painting (`high_level.rs`, `mask_tools.rs`) |
| ✅ Done | L | S | Cursor snap indicator in polygon mode | PlotInteraction.py:485-621 | W15: `draw_polygon_first_point` renders silx's first-point close target — an unfilled box of half-size `close_threshold_px` around the first vertex — throughout the polygon draw (RoiCreate + `show_with_draw`); `plot_widget.rs`, `interaction.rs::close_threshold_px()` |
### items
| status | P | E | feature | silx ref | gap |
|---|---|---|---|---|---|
| ✅ Done | M | M | Histogram bin alignment (left/center/right) | histogram.py:53-85 (`_computeEdges`, `setData(align=)`) | W15: `HistogramAlign` {Left,Center,Right} + pure `histogram_edges(positions, align)` mirror silx `_computeEdges` (Left=positions are left edges, append `x[-1]+last_gap`; Right=right edges, prepend `x[0]-first_gap`; Center right-aligns then shifts each edge left by half its following gap; lone position uses unit gap). Public `PlotWidget::add_histogram_aligned`/`_with_legend` take N positions + N counts + align. 7 headless tests (each align, single-position unit gap, non-uniform center, empty, step-value composition). The GPU add itself stays unverified |
| ✅ Done | M | L | Scatter mode-specific picking | scatter.py:804-860 | All modes wired into the live pick (`ScatterView::show_position_info` dispatches by mode: REGULAR_GRID cell→index, BINNED_STATISTIC bin→nearest-member via `nearest_candidate_in_data`, POINTS/SOLID nearest-pixel). IRREGULAR_GRID now closed by the triangle-mesh render redesign: `irregular_grid_triangles` builds a flat-shaded quadrilateral cell per point (silx `_quadrilateral_grid_as_triangles`), and `irregular_grid_pick` maps the cell under the cursor back to its point (silx picked-vertex `// 4`). See row 1086. |
| ✅ Done | M | M | Image per-pixel validity mask before upload | items/image.py:209-251 | `ScalarMask` + Plot2D wiring (`8ef46c2`) exist; `ScalarMask::apply` sets masked pixels to `f32::NAN`, now rendered transparent by the shader's non-finite → nan_color branch (`0356d5c`, with row 1140). Composite GPU render verified structurally (WGSL validates against naga); on-screen pixels GPU-unverified |
| ✅ Done | M | L | Marker custom-callback constraint | items/marker.py:208-235 | Arbitrary `setConstraint(fn)` form ported as `Marker::drag_with(to, constraint)` — the closure receives the cursor's data target `(x,y)` and returns the filtered position, exactly like silx's callable (`setPosition` does `x,y = getConstraint()(x,y)`). `drag` now delegates to `drag_with` via `apply_constraint`, so `drag_with` is the single owner of "filter target → update kind coords". The closure stays call-time state (not stored) so `Marker` keeps `Clone`/`Debug`/`PartialEq`. H/V presets still on the marker as `MarkerConstraint`. Headless tests: point filtered, vline/hline single-axis, non-draggable no-op, preset delegation |
| ✅ Done | M | M | Histogram filled-region picking | histogram.py:244-290 (`__pickFilledHistogram`) | Wave 7: pure `pick_histogram(edges, values, baseline, x, y) -> Option<usize>` — strict bbox gate (x over edge span, y over `[min(0,vmin), max(0,vmax)]`), `searchsorted(edges, x, side="left")-1` (clamped) bin index via `partition_point`, inclusive y test against the bar `[baseline, value]`/`[value, baseline]`. None on malformed/all-NaN/out-of-box/no-hit |
| ✅ Done | M | L | Image per-pixel alpha map | items/image.py:462-500 (get/setAlphaData) | Per-pixel alpha ported end-to-end on the scalar-image GPU path (analogous to the nan_color shader work): `ImageData` gained `alpha_map: Option<Vec<f32>>` (+ `with_alpha_map`, length-validated `w*h`), `ImageSpec`/`ImagePixelsSpec` carry it (`with_alpha_map`, scalar-only — RGBA ignores it as in silx), and `image_data_from_spec` reduces it by the same blocks (mean coverage) when the data is aggregated so it matches the displayed grid. GPU: `ImageParams` gained a `has_alpha_map` flag (+ explicit pad, struct now 144 B, std140-tested), the scalar bind-group layout a binding-5 unfilterable-float alpha texture, and `ImagePipeline` a shared 1×1 dummy bound when an image has no map; each tile uploads its `extract_subgrid` slice of the map to its own R32Float texture. `image.wgsl` samples the alpha texture under the image's nearest/linear interpolation (hand-rolled bilinear, R32Float unfilterable) and multiplies it into the global alpha on both the normal and nan_color paths. The `ImageData→ImageSpec` bridge (`image_spec_from_data`) carries the map so `add_image_data` keeps it. GPU-verified by a headless wgpu readback (`tests/image_alpha_map.rs`): a left-opaque/right-transparent map over a uniform teal image leaves the left half teal and the right half background, while an unmasked control render shows teal on both halves. Re-upload preservation is closed too: `RetainedItemData::Image` retains the map and the single-owner `ImageDisplayAttrs::apply` restores it on every re-upload path (level edit / autoscale / median filter / set_image_alpha), GPU-verified by a level-edit re-upload that keeps the right half masked. |
| ✅ Done | L | L | Marker text anchor/alignment | items/marker.py | `TextAnchor` now wired into `chrome::draw_markers`: label placement goes through the pure, tested core — `TextAnchor::pixel_offset` ports silx `_TextWithOffset.__get_xy` (left→+x/right→−x, top→+y-down/bottom→−y), then `rect_offset` positions the galley so the named anchor lands on the marker point. Per-kind defaults match silx `addMarker`: point & vline `ha="left"`→`TopLeft`, hline `ha="right",va="top"`→`TopRight` (text at the right edge). Point/vline padding `(5,3)`/`(size/2+3,3)`, hline `(5,3)`. Anchor/offset core headlessly tested; the painter draw stays GPU-unverified |
| ✅ Done | L | M | Shape overlay-flag separate data layer | items/shape.py:54-73 (`isOverlay`) | `draw_shapes`/`draw_lines` take an `overlay: bool` filter; `PlotWidget` runs two passes — non-overlay shapes/lines render in the base data layer (above grid, UNDER the overlay items: ROIs, markers, crosshair), overlay shapes/lines (default) render in the overlay layer on top of the chrome (silx base-render vs `_drawOverlays`). `Line` shares the `_OverlayItem` base so it gets the same split. Field/setter model tested; the render-layer split is GPU/painter-unverified |
| ✅ Done | L | L | ImageStack lazy URL/HDF5 loading | items/image.py:593-669 | Lazy loading landed (Composite-views rows 1571 lazy + 1573 prefetch): `ImageStack::set_sources`/`set_loader` load each slot on a background thread via the pluggable `FrameLoader`, with a prefetch radius (silx `_preFetch`/`N_PRELOAD`). Concrete `Hdf5FrameLoader` reads `file::dataset` (a 2D dataset) and `file::dataset::index` (one slice of a 3D `[N,H,W]` stack — only that frame is read off disk) through `render::save::read_image_hdf5`/`read_image_hdf5_slice` on the pure-Rust `rust-hdf5` crate; float datasets only (4-byte f32 / 8-byte f64→f32), with the dep's dtype-class limitation (only element byte size is exposed) documented, not silently masked. 8 read/loader unit tests + the 4 lazy/prefetch kittest. |
| ✅ Done | L | L | CompareImages SIFT keypoint alignment | CompareImages.py | `CompareAlignment::Auto` + `core::sift_align` port silx `__createSiftData`→`LinearAlign`: SIFT keypoints/descriptors + Lowe ratio match + 6-param affine least squares + bilinear warp of B onto A's grid, on the pure-Rust `lowe-sift` crate. Frozen rows 1547/1548 carry the detail. |
### roi
| status | P | E | feature | silx ref | gap |
|---|---|---|---|---|---|
| ✅ Done | H | S | Per-instance ROI color on the **live** plot | items/_roi_base.py:389-405 | Wave 13: unified `plot.rois` to `Vec<ManagedRoi>` (single source of truth); the live render `plot_widget.rs → chrome::draw_rois` resolves each ROI's color as `managed.color.unwrap_or(plot.roi_color)` via pure `chrome::roi_appearance`. On-screen pixels GPU/PlotWidget-UNVERIFIED; appearance resolution headlessly tested |
| ✅ Done | H | M | ROI label/name text on the live plot | items/_roi_base.py:492-511 (`_updateText`) | Wave 13: `ManagedRoi.name` plumbed through `chrome::roi_appearance.name` into the live `draw_rois` path (empty name → no label). Same single-collection fix as the color row |
| ✅ Done | H | S | ROI selection/highlight on the live plot | tools/roi.py:528-590 (`setHighlighted`) | Wave 13: `Plot::set_current_roi` (sole owner of `ManagedRoi.selected`) drives the live highlight; `draw_roi` thickens the current ROI's stroke. Invariant (exactly one selected; out-of-range clears; remove adjusts index) headlessly tested |
| ✅ Done | H | L | CurvesROIWidget integration | CurvesROIWidget.py:62-400 | W15: `CurvesRoiWidget` shows a per-ROI table (`ROI \| From \| To \| Raw Counts \| Net Counts \| Raw Area \| Net Area`) over the active curve; `PlotWidget::feed_curves_roi_stats`/`show_curves_roi_widget` reduce each x-span ROI via the pure `curve_roi_counts` (silx `computeRawAndNetCounts`/`computeRawAndNetArea`: array-order selection, linear-endpoint background, numpy-trapezoid). Pure core (`curve_roi_counts`) + row-building (`curve_roi_rows`) headlessly tested; the active-curve feed + render stay GPU-unverified. Example `high_level_curves_roi` |
| ✅ Done | H | L | ROIStatsWidget display | ROIStatsWidget.py | W15: `RoiStatsWidget` renders one row per ROI (`ROI \| N \| min \| max \| mean \| sum \| integral`) over the active item; `PlotWidget::feed_roi_stats`/`show_roi_stats_widget` reduce each ROI via `image_roi_stats`/`curve_roi_stats` and follow the active item + live ROI list. Row-building (`roi_stats_rows`) headlessly tested (image/curve per-ROI, empty); the active-item feed + render stay GPU-unverified. Example `high_level_roi_stats` uses it |
| ✅ Done | M | M | EllipseROI orientation/rotation | items/roi.py:875-1177 | `Roi::Ellipse` gained an `orientation` field (radii.0 along θ, radii.1 perpendicular; θ=0 ≡ the prior axis-aligned shape). All geometry is orientation-aware: vertex/handle positions (axis0 at θ, axis1 at θ+π/2), `contains` (project into the ellipse frame), `screen_rect` (rotated AABB), and the 27-point outline (silx rotated parametric form, mapped data→pixel). Dragging an axis handle off-axis sets that semi-axis AND rotates, mirroring silx `EllipseROI.handleDragUpdated`. Rotation geometry, contains-under-rotation, handle-follow, and AABB are headlessly tested |
| ✅ Done | M | M | CircleROI dedicated handle UI | items/roi.py:727-950 | Dedicated edit handles exist: `edges()` → `[Vertex(0) center, Vertex(1) perimeter]`, `move_edge` (V0 translate, V1 sets radius), rendered + hit-tested via `draw_roi_handles`/`roi_grab_at`. Tests `circle_handles_drag_center_and_radius`, `circle_perimeter_resize_works_under_inverted_y`. Matches frozen row 1171 ✅ (stale ◐ here from before Wave 12 handle-editing landed). |
| ✅ Done | M | M | CrossROI marker symbols | items/roi.py:133-185 | `1cbda70`: the drag handle is a square (silx `addHandle()` default symbol "s"); the full-span cross-hairs honor the ROI appearance's color/width/line-style. (silx's transient label-handle management is the manager-path concern, not the geometry) |
| ✅ Done | M | L | ArcROI interaction sub-modes | items/_arc_roi.py | Both sub-modes' geometry exist as pure cores and the live handle drag now dispatches on the selected mode: PolarMode edits via `Roi::move_edge` (mid/weight/start/end), ThreePointMode via `arc_from_three_points` (faithful `_createGeometryFromControlPoints`: closed-circle / collinear / general branches), `arc_control_points` (derive start/mid/end on the central radius, silx `_updateHandles`/`_updateMidHandle`), and `arc_three_point_drag` (move a control point → re-fit center+radius+angles, weight preserved). `arc_from_two_points` delegates to `arc_from_three_points` (one geometry owner). The live toggle lands: the mode button is the right-click "Interaction mode" submenu (row 1179), and `roi_apply_edge_drag` is the single owner that gates the handle drag — a ThreePointMode Arc exposes the three control-point handles (`arc_three_point_handles`, hit-tested by `arc_three_point_grab`, drawn by `chrome::draw_roi_handles`) and reshapes via `arc_three_point_drag`; every other mode uses the four polar handles + `move_edge`. 8 headless tests (geometry + the mode-gated grab/dispatch/handle-list); the on-screen handle glyphs are GPU-unverified as with every ROI handle draw. |
| ✅ Done | M | S | Manager `sigCurrentRoiChanged` signal | tools/roi.py:346-347 | Wave 13: `PlotEvent::CurrentRoiChanged { previous, current }` emitted by `set_current_roi` only when the selection actually changes (mirrors silx `sigCurrentRoiChanged`) |
| ✅ Done | M | M | ROI handle-symbol customization | items/_roi_base.py:735-759 | `draw_roi_handles` renders the silx `addHandle` role symbols: `+` for translate/center handles (role "translate") and a 6px square `s` for vertex/edge handles (role "default"). The `o` glyph is silx's `PolygonROI._handleClose` (role "user", transient creation marker, items/roi.py:1244) from the `RegionOfInterestManager` creation path; siplot creates polygons via the `PlotInteraction` draw path and renders its faithful close target — the `SelectPolygon.updateFirstPoint` fill=None box (`draw_polygon_first_point`) |
| ✅ Done | M | M | ROI line style (dash/dot) on canvas | items/_roi_base.py (LineMixIn) | Wave 13: `ManagedRoi.line_style` (`RoiLineStyle::{Solid,Dashed,Dotted}`) resolved into `RoiAppearance.line_style` and drawn by the live path; manager combo edits it |
| ✅ Done | M | S | ROI line width on canvas | items/_roi_base.py:245 | Wave 13: `ManagedRoi.line_width` resolved into `RoiAppearance.line_width` on the live `draw_rois` path (was hardcoded 1.0pt); manager drag-value edits it |
| ✅ Done | M | S | Manager default-color application | tools/roi.py:782-797 | Wave 13: added `Plot::roi_color` (silx-default red) applied as the per-ROI fallback; manager color buttons now write `managed.color` and apply on the live plot |
| ✅ Done | M | M | ROI edge-position constraints | items/roi.py:666-718 + per-class | All silx-real edge constraints are enforced: Rect/Range handles flip around the fixed opposite (silx `RectangleROI._setBound` min/max) instead of collapsing; Arc keeps `inner<=outer` and `inner>=0` (`.max(0.0)` in `move_edge`); Band keeps `width>=0` and the orthogonal width-handle constraint (normal projection `.max(0.0)`, silx `__handleWidthUp/DownConstraint`). silx's ROI classes have no ratio-lock, grid-snapping, or inter-ROI collision behavior to port |
| ✅ Done | L | L | BandROI sub-modes (Bounded/Unbounded) | items/_band_roi.py:172-250 | **Mis-attribution corrected:** silx BandROI has no "rotation sub-mode" — band orientation is already free via the begin/end handles (siplot `move_edge` Vertex 0/1). silx's actual sub-modes are `BoundedMode` (drawn as the 4-corner polygon, siplot's default via `band_corners`) and `UnboundedMode` (drawn as three parallel infinite lines). The Unbounded geometry core is `Roi::band_lines() -> BandLines` (faithful `BandGeometry.slope`/`intercept`/`edgesIntercept`), a `Sloped`/`Vertical` sum type so no field carries silx's `inf`-slope dual meaning, spanned to the view by the pure `Roi::band_unbounded_segments(x_range, y_range)`. The live toggle now lands: the mode button is the right-click "Interaction mode" submenu (row 1179/185), and `chrome::draw_roi` swaps the bounded polygon for the three view-spanning lines when `interaction_mode == BandUnbounded`. The spanning geometry is headlessly tested (7 tests) and the live draw is GPU-verified by a headless wgpu render test (`tests/roi_band_unbounded_render.rs`: UnboundedMode reaches the data-area left edge, BoundedMode stays at the band extent). |
| ✅ Done | L | M | ROI creation preview polish | tools/roi.py:493-510, 1101-1175 | Live overlay + polygon close target (`draw_polygon_first_point`, silx `SelectPolygon.updateFirstPoint`) present, and the mode indicator now lands: `PlotInteractionMode::roi_creation_message`/`PlotWidget::roi_creation_message` produce silx's `InteractiveRegionOfInterestManager.getMessage` string ("Select {SHORT_NAME}s ({n} selected)") for a host status bar (headlessly tested). Closes frozen row 1175. |
| ✅ Done | L | S | ROI fill enable/disable toggle | items/roi.py:531-539 | Wave 13: `ManagedRoi.fill` plumbed to `RoiAppearance.fill`; `set_roi_fill` API + manager checkbox toggle the interior fill on the live plot (silx `setFill`) |
| ✅ Done | L | S | Distinct Horizontal/VerticalLineROI kinds | items/roi.py:366-510 | `Roi::HLine { y }`/`Roi::VLine { x }`: single-Y/single-X full-span line ROIs distinct from HRange/VRange bands (one edge, one handle, exact-coord `contains`). `RoiDrawKind::HLine`/`VLine`→`DrawMode::HLine`/`VLine` (silx `SelectHLine`/`SelectVLine`), chrome draws a styled full-span line. Geometry/grab/draw-build tested; GPU draw unverified |
| ✅ Done | L | M | ROI right-click context menu | tools/roi.py:625-642 (`_feedContextMenu`) | Per-ROI canvas menu (silx `_createMenuForRoi`): ROI-name title + "Make current" (`set_current_roi`) + "Remove" (`remove_roi`); the target ROI is captured via `roi_grab_at` and held in `ui.data` for the menu's lifetime, and the high-level owner applies the signalled intent so CurrentRoiChanged/RoisCleared fire. For Arc/Band ROIs the menu now nests an "Interaction mode" submenu (silx `createMenuForInteractionMode`) over the ported `InteractionModeMixIn` model, applied via `set_roi_interaction_mode` → `RoiInteractionModeChanged`. Closes frozen row 1179. |
| ✅ Done | L | L | ROI save/load from file | CurvesROIWidget.py:194-210 (dictdump) | Data-layer save/load + widget wiring ported: `core::roi_io` `encode_rois`/`decode_rois` (hand-written line-oriented text format, no serde — same manual approach as the `.npy` mask path) round-trip every `Roi` variant plus name/color/line width+style/gap color/fill; the transient `selected` flag is omitted (silx `ROI.toDict` likewise). `save_rois(path,…)`/`load_rois(path)` are the file wrappers (silx `CurvesROIWidget.save`/`load(filename)`). `PlotWidget::save_rois_to_path`/`load_rois_from_path` are the testable widget-level wrappers (load replaces the set via `clear_rois`+`add_managed_roi`, emitting `RoisCleared` + N×`RoiAdded`); `save_rois_dialog`/`load_rois_dialog` open the native rfd file picker like the figure `save_dialog` (GPU/UI-unverified shims). 10 tests (9 `roi_io` round-trip/error cases + a GPU-backed `PlotWidget` save→mutate→load round-trip in `tests/roi_events.rs`). |
| ✅ Done | L | M | ROI keyboard shortcuts + naming UI | tools/roi.py:1072-1098 | Faithful port of silx `InteractiveRegionOfInterestManager.eventFilter`: pure `roi_key_action(key, command) -> RoiKeyAction` maps Enter→Validate and Delete/Backspace/Ctrl+Z(⌘Z)→UndoLast. **Mis-attribution corrected:** silx binds *no* per-shape keys ("R for Rect" was speculative) and has no named-creation dialog — ROI naming is via the manager (`ManagedRoi.name`, already present). Wired in `plot_widget` RoiCreate: the key dispatch runs through `roi_key_action`; UndoLast signals `roi_removed = last` to the single removal owner (`high_level::remove_roi`, like the context-menu Remove). Mapping headlessly tested; the live key/event wiring is GPU/UI-unverified |
### colormap-colorbar-mask
| status | P | E | feature | silx ref | gap |
|---|---|---|---|---|---|
| ✅ Done (W15) | H | M | ColormapDialog Stddev3/Percentile from raw pixels | ColormapDialog.py:240-280 | `ColormapDialog::apply` now autoscales from the active image's raw pixels (`Plot2D::get_image_pixels_raw`) via the pure `autoscale_range` (delegates to `AutoscaleMode::range` with the dialog's mode + percentiles) — Stddev3 (mean ± 3·std clamped to data range) and Percentile compute the real distribution range instead of falling back to MinMax; falls back to aggregated stats min/max only when no raw scalar pixels are retained |
| ✅ Done (W15) | H | M | Mask drawing tool: Rectangle | MaskToolsWidget.py:805-826 | On-plot rectangle draw: `MaskTool::draw_mode` arms a `DrawState` driven by `handle_shape_draw`; `rect_params_to_cells` (silx `int()` truncation) → `update_rectangle` on finish; `ImageView::handle_mask_shape_draw` paints the rubber-band preview (reuses `paint_draw_preview`/`feed_draw_state`) |
| ✅ Done (W15) | H | M | Mask drawing tool: Polygon | MaskToolsWidget.py:840-847 | On-plot polygon draw (click-to-add, snap-close) via the shared shape-draw wiring: `MaskTool::draw_mode` arm + `polygon_vertices_to_cells` (cast int64, swap `(x,y)`→`(row,col)` per silx `[:, (1,0)]`) → `update_polygon`/`polygon_fill_mask` on finish |
| ✅ Done (W15) | H | L | Mask drawing tool: Ellipse | MaskToolsWidget.py:828-838 | On-plot ellipse draw via the shared shape-draw wiring: `MaskTool::draw_mode` arm + `ellipse_params_to_cells` (center cast int64, y-semi→`radius_r`, x-semi→`radius_c`; radii stay float per silx) → `update_ellipse` on finish |
| ◐ Partial | M | M | Colormap catalog: matplotlib-dynamic loading | colors.py:938-955 | `6b0b2f9`: catalog grown 15 → 45 by shipping the full `colorous` gradient set (d3-scale-chromatic / ColorBrewer overlap with matplotlib) statically. True runtime matplotlib-dynamic loading is impossible without a Python/matplotlib dependency (out of scope); the static catalog is the faithful substitute |
| ✅ Done | M | M | ColormapDialog histogram display | ColormapDialog.py:1227-1295 (`computeHistogram`), :831-848 (display) | Wave 6: pure `compute_histogram` (silx `nbins=clamp(2,min(256,⌊√N⌋))`, finite-min/max or supplied range, log-space binning with edges → linear) + `set_histogram`/`histogram`/`clear_histogram` (silx `setHistogram`/`getHistogram`). Dialog auto-derives from the active image's raw pixels (recompute only on (re)open / normalization change); `draw_histogram_panel` renders normalized gray bars + a colormap gradient strip across `[vmin,vmax]` + vmin/vmax markers. Pure compute + API headlessly tested; on-screen panel GPU-unverified |
| ✅ Done | M | M | Mask threshold UI (below/between/above) | _BaseMaskToolsWidget.py:265-294, :848-937, :1182-1225 | Wave 7: threshold row in the ImageView mask-draw toolbar — `ThresholdMode` selector + min/max fields shown per mode (min for below/between, max for between/above, silx minLineEdit/maxLineEdit visibility) + `Mask below/between/above` Apply button calling the tested `update_threshold` (below→min, above→max, between→both) at the current level, commit + re-upload. `MaskToolsWidget` holds `threshold_mode`/`threshold_min`/`threshold_max` (silx group defaults: below checked, edits at 0; unit-tested). Toolbar row GPU-unverified |
| ✅ Done | M | L | Mask file save (npy/edf/tif/h5/msk) | MaskToolsWidget.py:104-141 | ALL five silx formats encode now ported: `encode_mask_edf` (fabio-style ASCII `{…}` header + raw `uint8` body, 512-aligned), `encode_mask_tiff` (grayscale `uint8` via the `tiff` crate), `write_mask_hdf5` (2D `uint8` dataset via pure-Rust `rust-hdf5`), and `encode_mask_msk` (Andy Hammersley fit2d binary bit-pack, byte-verified against fabio `Fit2dMaskImage`) — with `write_edf`/`write_tiff`/`write_msk`/`save_h5` + `save_*`/`save_mask_*` wrappers mirroring the npy path. `save_mask_dialog` opens a native rfd save picker (npy/edf/tiff/hdf5/msk filters) and dispatches by extension via the single-owner `resolve_mask_save_format` (missing→npy default, unsupported rejected — faithful to silx `save`). `show_toolbar` now renders silx's `loadAction`/`saveAction` (Load.../Save... buttons). HDF5 save is faithful to silx's "a" mode (`open_rw` an existing file preserving its other datasets + delete-and-recreate the named dataset, else create). The HDF5 "Select a 2D dataset" picker is ported (silx `_selectDataset(SaveMode)`): `begin_h5_save` parks the choice and `show_toolbar`'s egui modal names the target dataset (default `mask`, existing datasets offered as overwrite shortcuts), driving `save_h5_dataset`. Deviation: the secondary "overwrite existing dataset?" QMessageBox is not reproduced (the modal lists the existing datasets and OK overwrites directly). |
| ✅ Done | M | L | Mask file load (npy/edf/tif/h5/msk) | MaskToolsWidget.py:589-629 | ALL five silx formats decode+apply a 2D uint8 mask through the shared `apply_loaded_mask` crop/pad+commit owner: `read_npy`, `read_edf` (fabio `{…}` header), `read_tiff` (single-channel `uint8`), `load_h5` (`read_mask_hdf5`/`read_mask_hdf5_auto` via `rust-hdf5`, `ndims()==2` guarded), and `read_msk` (`decode_mask_msk`, fit2d bit-unpack to a binary mask, byte-verified vs fabio) — with `load_*`/`load_mask_*` wrappers. `load_mask_dialog` opens a native rfd open picker (npy/edf/tiff/hdf5/msk) dispatching by extension via `resolve_mask_load_format` (unknown extension rejected, faithful to silx `load`), reachable from the `show_toolbar` Load... button. The interactive Qt "Select a 2D dataset" picker is ported (silx `_selectDataset(LoadMode)`): `begin_h5_load` enumerates 2D datasets (`mask_datasets`) and parks the choice; `show_toolbar`'s egui modal lists them and drives `load_h5_dataset` on the chosen one. GPU-verified by the egui_kittest modal test (`tests/mask_h5_picker.rs`). |
| ✅ Done | M | S | Mask transparency/alpha slider UI | _BaseMaskToolsWidget.py:554-577 | Wave 7: Transparency slider in `MaskToolsWidget::show_toolbar` (the coloured-overlay toolbar) routes through the tested `set_transparency` (silx `transparencySlider` → `_setMaskColors`: selected level at this alpha, others at half) and re-renders the overlay LUT. Live UI verified (this wave): kittest test (`tests/mask_transparency_slider.rs`) renders `show_toolbar`, drags the rendered "Transparency" slider to its low end, and asserts the widget's `transparency()` (new getter, symmetric with `set_transparency`) equals the slider's displayed value and drops near the 0.0 minimum — proving the slider drives `set_transparency`. N/A for ImageView's mask (rendered as NaN holes, no overlay alpha) |
| ✅ Done | M | S | Mask Mask/Unmask toggle + Ctrl modifier | _BaseMaskToolsWidget.py:790-810 | `d85c751`: `mask_state` Mask/Unmask radio for shape draws + `effective_do_mask = base ^ ctrl` (silx `_isMasking()`); Ctrl (egui `command`, = Cmd on macOS per Qt) inverts all draws, captured once per pencil stroke. Pencil/eraser keep their brush direction. Truth-table tested; toolbar GPU-unverified |
| ✅ Done | M | S | Mask invert per-level UI | _BaseMaskToolsWidget.py:207-218 | Wave 7: "invert" button in the ImageView mask-draw toolbar calls the tested `MaskToolsWidget::invert`. `d85c751` adds the silx Ctrl+I shortcut (egui `COMMAND`+I) in `MaskToolsWidget::show_toolbar`. Button/shortcut GPU-unverified |
| ✅ Done | M | S | Mask not-finite (NaN/Inf) button | _BaseMaskToolsWidget.py:296-304 | `MaskToolsWidget::mask_not_finite` (silx `updateNotFinite`: stencil `!isfinite`, tested) + Wave 7 "mask non-finite" button in the ImageView mask-draw toolbar (masks the active image's NaN/Inf pixels at the current level, commits, re-uploads; enabled when mask geometry matches the image). Button/overlay GPU-unverified |
| ✅ Done | L | S | Mask per-level color override UI | _BaseMaskToolsWidget.py:394-398, 973-1042 | API done earlier (`set_mask_colors`/`reset_mask_colors`, faithful `setMaskColors`/`resetMaskColors`). This adds the getter `current_mask_color` (silx `getCurrentMaskColor`: per-level override if set, else base color, tested) and the `MaskToolsWidget::show_toolbar` UI: a color swatch reading/writing the current level's override + a "Reset color" button. Getter tested; toolbar swatch GPU-unverified |
### composites
| status | P | E | feature | silx ref | gap |
|---|---|---|---|---|---|
| ✅ Done | M | S | ImageView `getHistogram()` public API | ImageView.py:699-725 | Wave 7: `histogram(axis) -> Option<ImageProfileHistogram { data, extent }>` (silx `getHistogram` `{data, extent}` dict). `ImageHistogramAxis::{X,Y}` → per-column / per-row sums (shared `image_column_sums`/`image_row_sums` helpers, also feeding `rebuild_histograms`); extent `(0, width)`/`(0, height)`, end-exclusive |
| ✅ Done | M | S | ImageView side-histogram show/hide API | ImageView.py:552-566 | Wave 7: `is_side_histogram_displayed`/`set_side_histogram_displayed` (silx `is/setSideHistogramDisplayed`). `show()` gates the top histogram, the radar viewport sync, and the bottom vertical-histogram+radar column on the flag — silx hides `_histoHPlot`/`_histoVPlot`/`_radarView` as one cluster; strip extents collapse to 0 via `side_histogram_extent` so the image reclaims the space |
| ✅ Done | M | S | ImageView `valueChanged` signal | ImageView.py:381,585-601 | Wave 7: `value_changed() -> Option<(col, row, value)>` (silx `valueChanged` emitted at `_imagePlotCB`). Pure `image_value_at` maps cursor data coords to a pixel index via silx `int((x-origin)/scale)` (identity geometry), guarded against the origin and the pixel grid; `None` when off-image (silx emits nothing) |
| ✅ Done | M | M | ScatterView position-info panel API | ScatterView.py:90-101 | `show_position_info(ui, &PlotResponse)` embeds `PositionInfo` with X/Y/Data/Index, snapping to the picked point (`scatter_pick_pixels`, pixel-space, silx `_pickScatterData`) |
| ✅ Done | M | S | ScatterView get/setSelectionMask API | ScatterView.py:412-418 | `selection_mask()`/`set_selection_mask(&[u8])` (silx getSelectionMask/setSelectionMask): per-point u8 levels; set validates length == point count, commits to undo history |
| ✅ Done | M | M | StackView perspective selection | StackView.py:364-397 | `StackPerspective` {Axis0,Axis1,Axis2} + `set_perspective`/`perspective`/`perspective_ui` combo pick the browse dimension |
| ✅ Done | M | M | StackView 3D transposition | StackView.py:409-441 | `set_volume([d0,d1,d2])` + `stack_frame` re-slice per perspective (silx transpose (1,0,2)/(2,0,1)); axis labels via `dimension_axis_labels` |
| ✅ Done | M | S | StackView dimension labels | StackView.py:799-827 | `set_dimension_labels([&str;3])`/`dimension_labels()` (silx setLabels/getLabels); empty→default `"Dimension N"`; axis labels rotate with perspective |
| ✅ Done | M | M | CompareImages vline/hline separator modes | CompareImages.py:124-133, 422-445 | Wave 7: `HalfHalf` is the silx VERTICAL_LINE composite (A left / B right); `CompareMode::SplitHorizontal` = HORIZONTAL_LINE (A top / B bottom) via the pure `split_composite` helper + toolbar button. The split position is now an **on-plot draggable line marker** (silx `__vline`/`__hline`) — a blue draggable `Marker::vline`/`hline` placed at `split * extent`, recreated when the orientation changes; dragging it reads the data position back into `split` (silx `__separatorMoved`), clamped to `[0,1]` (silx `__separatorConstraint`). The slider is kept as a convenience and stays in sync. Verified by drag-simulation (`tests/compare_separator.rs`, 3 tests) |
| ✅ Done | M | M | ComplexImageView amplitude-range dialog | ComplexImageView.py:50-155, items/complex.py:62-82, :199-212 | Wave 7: `ComplexMode::Log10AmplitudePhase` + `amplitude_phase_log_rgba(data, max, delta)` port silx `_complex2rgbalog` (clamp to displayed max `smax`, `log10(\|z\|+1e-20)`, shift by `a.max()-delta`, normalize over `delta` decades) — amplitude → HSV value (siplot convention). `set_amplitude_range_info(max, delta)` / `amplitude_range_info()` = silx `_setAmplitudeRangeInfo`/`_getAmplitudeRangeInfo`, defaults max=None autoscale + delta=2 (`DEFAULT_AMPLITUDE_DELTA`). Composite math unit-tested. `87cd1d4` adds `show_amplitude_range_controls` (autoscale checkbox + Displayed Max + delta, silx `_AmplitudeRangeDialog`) with the tested `data_max_amplitude` seed. Live UI verified (this wave): kittest+wgpu test (`tests/complex_amplitude_controls.rs`, 3 tests) renders the controls + the image each frame (so the range change dispatches the GPU rebuild via `show`): clicking "autoscale" off seeds the displayed max from the data max (5.0) and re-checking clears it to `None`; dragging the enabled "Displayed Max." drag-value raises it above the seed; dragging the delta drag-value raises the log-decade count above the 2.0 default — all observed through `amplitude_range_info()` |
| ✅ Done | L | L | StackView 3D-profile toolbar | StackView.py:948-951 | Extraction (`StackView::stack_aligned_profile`/`stack_line_profile` over the loaded volume + current perspective, silx `ProfileImageStack{HorizontalLine,VerticalLine,Line}ROI`) now drives the full tool: `show_profile3d_toolbar` (the h-line / v-line / line ROI buttons + a 1D/2D `profileType` toggle, silx `Profile3DToolBar` + `_DefaultImageStackProfileRoiMixIn.setProfileType`); a primary drag on the image runs `handle_profile_drag` → `show_profile`, which routes to the 1D current-frame curve window (`ProfileWindow`, silx profileType `"1D"`) or the new `StackProfileWindow` 2D image of the stacked profile (silx profileType `"2D"`, `core.ImageProfileData(colormap=item.getColormap())`). Verified end-to-end by drag-simulation (`tests/stack_profile_window.rs`, 5 tests: 2D/1D window opens on drag, dimension toggle hides the inactive window, line over volume, 2D needs a volume). 2D requires `set_volume` (flat-frames mode has no stack source) |
| ✅ Done | L | M | StackView calibration (per-axis scale/origin) | StackView.py:551-566 | `core::Calibration` {None, Linear} + `StackView::set_calibrations`/`calibrations`/`calibrations_axes`/`image_z` mirror silx `getCalibrations(array/axes)` + `_getImageOrigin/Scale/Z`; displayed image placed via the calibrated origin/scale (`add_image_with_geometry`). Pure helpers tested; GPU placement unverified |
### actions-toolbars
| status | P | E | feature | silx ref | gap |
|---|---|---|---|---|---|
| ◐ Partial | M | M | Print: native dialog + printer submission | actions/io.py:747-845 | `print_graph()` GPU-readback shim exists; no native printer dialog or print-to-device backend |
| ✅ Done | M | M | ColormapAction floating-dialog toggle | actions/control.py:352-448 | `f3a9c54`: `ColormapDialog::toggle_button` is a checkable toolbar action that reflects/flips `dialog.open` (silx `ColormapAction`); wired into the `high_level_colormap_dialog` example toolbar row. Toolbar GPU-unverified |
| ✅ Done | M | M | ScatterToolBar viz-mode selector button | tools/toolbars.py:273-362 | `4477bc2`: `ScatterView::show_toolbar` adds a visualization-mode ComboBox (`ScatterVisualization::ALL`, silx order) that calls `set_visualization` on the picked mode. Toolbar GPU-unverified |
| ✅ Done | M | S | ProfileOption + line-width/method (sum vs mean) | PlotToolButtons.py:227-301 | `340b80c`: `ProfileWindow` shows a Width DragValue + Mean/Sum ComboBox that drive `profile_for_roi` through the line-width/method extractors (no longer mean-only). Window UI GPU-unverified |
| ✅ Done | M | S | ProfileToolButton (1D vs 2D selector) | PlotToolButtons.py:304-391 | Standalone `widget::tool_buttons::ProfileToolButton`: a reusable dropdown button with the silx 1D/2D dimension state (`set_dimension` rejects out-of-range, `dimension`), the silx STATE action/tooltip strings, and an egui popup `ui() -> Option<u8>` (silx `sigDimensionChanged`). State core headlessly tested; the popup render is GPU/UI-unverified |
| ✅ Done | M | M | SymbolToolButton (marker symbol + size) | PlotToolButtons.py:394-477 | Standalone `widget::tool_buttons::SymbolToolButton`: a reusable dropdown with a size slider (silx range `1..=20`, default 6.0) over `Symbol::ALL`, emitting `SymbolToolChange::{Symbol,Size}` (silx `_markerChanged`/`_sizeChanged`). `set_size` clamps to the slider range; state core headlessly tested; the popup render is GPU/UI-unverified |
| ✅ Done | M | M | ScatterVisualizationToolButton | PlotToolButtons.py:550+ | `4477bc2`: the visualization-mode selector now lives in the `ScatterView` toolbar (`ScatterVisualization::ALL` ComboBox → `set_visualization`). Toolbar GPU-unverified |
| ✅ Done | L | L | SaveAction PDF/EPS/JPEG | PlotWidget.py:3232-3242 | All three exported, hand-written with no new dep. EPS/PDF are SVG-style raster-embedding substitutes for silx's matplotlib vector output: `render::save::encode_eps` (PostScript `colorimage`, Y-flip matrix, ASCII-hex body) and `encode_pdf` (single-page `/DeviceRGB` `/ASCIIHexDecode` image XObject with a byte-offset xref). JPEG is a hand-written baseline JFIF encoder (`render::jpeg::encode_jpeg` — T.81 Annex K tables IJG-scaled to quality 90, 4:4:4, canonical Huffman, 0xFF stuffing), the same on-disk container PIL writes for silx. `SaveFormat::Eps`/`Pdf`/`Jpeg` + `from_extension` + dispatch + the native save-dialog filters wired. PNG/PPM/SVG/TIFF/JPEG/EPS/PDF supported; only `.ps` remains unmapped |
| ✅ N/A | L | S | Y2AxisAutoScaleAction | actions/control.py:172-230 | Not a silx feature: silx ships only `XAxisAutoScaleAction` + `YAxisAutoScaleAction` (no Y2 variant). The capability already exists (`set_y2_autoscale`, honored by the autoscale path); a Y2 toolbar button would be a non-silx UI extension, deliberately deferred (Wave 7 note: a third autoscale button bloats the row). Marked N/A rather than fabricate a silx counterpart. |
| ✅ Done | L | S | ClosePolygonInteractionAction | actions/control.py:653-683 | `DrawState::validate` ports silx `SelectPolygon._validate`: closes the in-progress polygon at its committed vertices regardless of cursor position (same `len > 2` gate as the snap-close path; no-op when idle / too few vertices / non-polygon mode). Wired in `apply_interaction` to the Enter key while a `RoiCreate(Polygon)` draw is active, so a polygon finishes without snapping back to the first point. `validate` unit-tested; the key wiring is GPU-unverified |
| ☐ — | L | S | OpenGLAction (backend selector) | actions/control.py | N/A: siplot is wgpu-only (intentional) |
| ✅ Done | L | M | LimitsToolBar (editable X/Y min/max fields) | tools/toolbars.py | `PlotWidget::show_limits_toolbar` renders the silx X/Y min/max FloatEdit row; edits apply via `set_graph_x_limits`/`set_graph_y_limits` with `ordered_limits` swapping reversed bounds (silx `_xFloatEditChanged`). Closes frozen row 1241. |
### stats-profile-fit-positioninfo
| status | P | E | feature | silx ref | gap |
|---|---|---|---|---|---|
| ✅ Done | M | M | Stats context (viewport/ROI masking, live binding) | stats/stats.py:143-600 | Live binding done (stale "pure engine only" note corrected). Viewport masking: `StatScope::OnLimits{x_range,y_range}` (the on-visible-data toggle) clips to the visible rectangle each recompute. Auto-recompute on viewport change: `StatsWidget::recompute` takes the current viewport every frame and (in `UpdateMode::Auto`) recomputes — the immediate-mode equivalent of silx's `_updateStats` on `sigVisibleDataChanged`. ROI masking is the separate `ROIStatsWidget` path (`image_roi_stats`/`curve_roi_stats`, `roi.contains` mask, rows above) — faithful to silx `_StatsContext`, which itself carries only the onlimits clip, no per-pixel user mask. Qt item-change signals are N/A (immediate mode rebuilds the rows from `item_records` each frame). |
| ✅ Done | M | M | StatsWidget live item binding | StatsWidget.py:200-700 | Bound to the live plot both ways (stale "not docked / no live binding" note corrected): `feed_active_stats`/`show_active_stats_widget` track the **active** item, and `feed_all_stats`/`show_all_stats_widget` track **all** items with retained scalar data (one row each, legend-labelled) — silx `setDisplayOnlyActiveItem`'s two modes, chosen by which method the host calls. Each recomputes from the live retained data every frame (Auto) honoring the on-visible-data viewport clip; items without retained scalar data (RGBA images, triangles, shapes, markers) are skipped. Composed pure cores (`retained_data_to_stats_input`, `Stats::for_curve`/`for_image`, `recompute` scope) headlessly tested; the `PlotWidget` feed + table render need a wgpu RenderState no test builds, so they are GPU/PlotWidget-unverified. |
| ✅ Done | M | M | Profile line width / method (mean vs sum) | tools/profile/rois.py:220-260, core.py:204-270, image/bilinear.pyx:391-466 | Wave 7: `ProfileMethod{Mean,Sum}` + `aligned_profile_values(.., position, roi_width, horizontal, method)` ports silx `_alignedFullProfile` (band of `roi_width` pixels, silx start/end placement, mean÷band or sum) for H/V; `rect_profile_values(.., method)` reduces the rectangle band by mean or sum; `line_profile_band(.., linewidth, method)` + `bilinear_sample` port silx `BilinearImage.profile_line`/`c_funct` for free lines (perpendicular bilinear band of `linewidth` px, mean/sum, edge-clamp interpolation, strict band bounds). All three extractors data-layer complete + tested against silx expectations. `340b80c` wires the UI width/method selectors (ProfileWindow Width DragValue + Mean/Sum ComboBox) to the extractors (row 232). Window UI GPU-unverified |
| ✅ Done | M | M | Fit range-selection UI (xmin/xmax) | FitWidget.py:336-361 | `ac45cd8`: a "Fit range" checkbox seeds `fit_range` from the finite x-extent (`default_fit_range_of`) and exposes xmin/xmax DragValues. Deferred: interactive on-plot range-drag (panel numeric UI only). Panel GPU-unverified |
| ✅ Done | M | M | PositionInfo snapping to nearest item | tools/PositionInfo.py:179-292 | Wave 7: `snap_to_nearest(cursor_px, candidates, threshold_px) -> Option<Snap>` ports the silx picking kernel (:236-292) — global-nearest point within `threshold²` (silx `<=`, ties to later, non-finite skipped), `SNAP_THRESHOLD_DIST = 5` logical px. Pure pixel-space math, unit-tested. The snapping-mode candidate selection is also a pure core: `SnappingMode` (the silx `SNAPPING_*` bitfield, `|`-combinable) + `snapping_candidates(mode, &[SnapItem]) -> Vec<usize>` port `_updateStatusBar`'s item selection (:196-244) — `CURVE`/`SCATTER` engage gate, `ACTIVE_ONLY` vs all-items (with silx's real asymmetry: histograms join CURVE only in the all-items path, and active-only ignores the visible filter), and `SYMBOLS_ONLY`. 6 headless tests. **Live wiring closed (this wave):** `PlotWidget::snap_cursor(cursor, mode) -> Option<Snap>` builds the `SnapItem` list from `item_records` (kind via `PlotItemKind→SnapItemKind`, `is_item_visible`, `curve_data.symbol`, active-item), runs `snapping_candidates`, projects each candidate's retained `Curve{x,y}` vertices through the cached axis-aware `data_to_pixel` (so a y2 curve snaps in its own axis), and returns `snap_to_nearest` within the radius — curves, histograms, AND scatters all participate (a base-widget scatter is a symbol-only curve-kind item that retains its points, so `SCATTER` mode snaps scatter points and `CURVE` mode snaps curve/histogram vertices). `PositionInfo::ui_snapped(ui, cursor, snapped)` reddens the value labels in the no-snap state (silx :200) and uses the normal style on a snap (:288). kittest-verified (`tests/position_info_snap.rs`): on-vertex snap, off-vertex no-snap, DISABLED/SCATTER-mode filtering, scatter-mode snap, uncached-transform no-snap |
| ✅ Done | M | M | FitWidget UI extras (editable params/constraints/peak-search/bg) | silx.gui.fit.FitWidget | Results table renders. **Background-model selector now wired**: `fit_widget.rs` `BACKGROUND_CHOICES` combo (silx `bgtheories.THEORY` order) → `core::fitting::fit_peak_with_background` (estimate the silx background theory, subtract, fit the peak on the residual, redraw the `background + peak` total; composes rows 254/255/256). The `Background::None` path is byte-identical to the prior fit. DEVIATION from silx: silx refines the analytic-background coefficients (Constant/Linear/Polynomial) *simultaneously* with the peak in one `leastsq` (`is_background=True` theories concatenated into one parameter vector); here the background is estimated once and held fixed — a free analytic-background constant would be collinear with siplot's single-`PeakModel` baked-in trailing bg parameter (singular covariance). Removing that baked-in constant for a faithful simultaneous fit is a `PeakModel` redesign tracked separately. 6 headless tests (`None`≡plain fit byte-identical, constant/linear peak recovery, `BACKGROUND_CHOICES` silx order, `background_label` round-trip+fallback, bad input). Combo GPU-unverified. **Multi-peak UI now wired**: `FitModelChoice::MultiGaussian` (silx `sum_gauss` theory) dispatches to `core::fitting::fit_multi_gaussian_full` — auto peak search seeded by `guess_fwhm` (row 256 cores), fit all peaks at once, packaged as an `IterativeFitResult` with per-peak `Height i`/`Center i`/`FWHM i` names + covariance so the generic results table renders N×3 params/errors with no special-casing. The background combo does not apply to it (the multi-gaussian model has no per-peak constant; silx `StripBackgroundFlag` off by default). 1 headless test (names/errors/curve align + both peaks recovered); combo GPU-unverified. **Per-parameter constraints UI now wired**: `core::fitting::fit_peak_constrained` fits a single `PeakModel` under a `Constraint` vector via `leastsq_constrained` (row 255); FitWidget keeps a per-model `constraints` vector (`ensure_constraints_len` clears it to all-`Free` on model change, silx-faithful) + a collapsing per-parameter constraint editor. `perform_fit_choice`'s no-background single-peak path has a 3-way precedence: all-`Free` → original `IterativeFit` (byte-identical), else → `fit_peak_constrained`; a selected background still takes precedence and ignores constraints. The constraint combo now exposes the **full** silx per-parameter code set `FREE`/`POSITIVE`/`QUOTED`/`FIXED`/`FACTOR`/`DELTA`/`SUM` (`UI_CONSTRAINT_KINDS` = silx `Parameters.code_options` minus the group-only `ADD`/`SHOW`/`IGNORE`, which a single-model fit has no analogue for): picking `QUOTED` reveals min/max `DragValue`s, and `FACTOR`/`DELTA`/`SUM` reveal a reference-parameter picker + the multiplier/offset/sum field. The selection logic is a pure, headlessly-tested seam — `constraint_kind` (code of an existing `Constraint`), `make_constraint` (silx default-seeding: `QUOTED [0,1]` placeholder, `FACTOR` ×1 / `DELTA` +0 / `SUM` 0 to the best reference), and `default_related_reference` (silx `getRelatedCandidates`: first other parameter not itself tied/ignored; `None`→selection rejected like silx `setCodeValue`). All seven feed the existing `fit_peak_constrained`/`fit_peak_from` → `leastsq_constrained` path (row 255). 5 fit-widget headless tests (`code_options` labels, payload-drop, default-seeding, no-candidate rejection, reference-skips-tied) on top of the core constraint tests (all-free recovers, FIXED holds bit-identical, count-mismatch rejects); editor widgets GPU-unverified. **Editable initial-param input now wired**: `core::fitting::fit_peak_from` is the single owner of the constrained-fit-from-explicit-`p0` path (`fit_peak_constrained` estimates `p0` then delegates); FitWidget keeps an editable `initial_params: Option<Vec<f64>>` (populated from each fit, reset on data/model change via `ensure_constraints_len` + `set_data`) shown as an `Initial` value column in the now-3-column `Parameters` table. The no-background single-peak precedence is now `(initial_params, all_free)`: `(None, true)` → original `IterativeFit` (byte-identical), `(Some p0, _)` → `fit_peak_from` (start from edited values), `(None, false)` → `fit_peak_constrained`. 1 headless test (`fit_peak_from` recovers from an off `p0`; estimate-seeded ≡ `fit_peak_constrained`); table GPU-unverified. The prior residual (`QUOTED` min/max + `FACTOR`/`DELTA`/`SUM` editor fields) is now closed — the full constraint editor is wired (see the constraints paragraph above) |
| ✅ Done | L | S | Profile cross-profile dual-curve display | tools/profile/rois.py:663-700 | `profiles_for_roi` returns a `Vec<ProfileCurve>`; a `Roi::Cross` yields BOTH the horizontal (row-through-cy) and vertical (col-through-cx) profiles, drawn together as two legended curves ("h profile"/"v profile") in one `ProfileWindow` (silx `ProfileImageCrossROI`'s hline+vline sub-ROIs). `curve_handles: Vec` rebuilds when the curve count changes. Extraction tested; window GPU-unverified |
| ✅ Done | L | L | Profile over stack (slice across 3D dim) | tools/profile/rois.py:1058-1165 | New `high_level`: `StackProfile{frame_count,profile_len,values}` (row-major `[frame, position]`) + private `stack_profile_with` core (profiles every frame via `stack_frame`, requires a uniform per-frame `profile_len`, `None` on mismatch/empty) behind pub `stack_aligned_profile` (band profile per frame via `aligned_profile_values`; silx `ProfileImageStackHorizontalLineROI`/`...VerticalLineROI`) and `stack_line_profile` (line-segment profile per frame via `line_profile_values`; silx `ProfileImageStackLineROI`). 4 headless tests; profile-window display GPU-unverified |
| ✅ Done | L | L | Fit background subtraction (const/linear/poly/strip/snip) | silx.math.fit.bgtheories.py + filters strip.c/snip1d.c | New `core::background`: `strip_background` (Jacobi peak-stripping, anchors + border-preserve, faithful to `strip.c`), `snip_background` (descending-window min clip, `snip1d.c`; silx applies no LLS), `polyfit`/`poly_eval` (numpy.polyfit/poly1d order via normal equations). `Background` enum {None, Constant=min(y), Linear, Strip{w,niter,factor}, Snip{w}, Polynomial{deg}} mirrors silx `bgtheories.THEORY` + defaults (StripWidth 2 / Iter 5000 / factor 1.0; SnipWidth 16; Linear/Poly fit on the strip background per `EstimatePolyOnStrip`). `compute`/`subtract` return the background curve / peak residual. 13 headless tests; wired into FitWidget via `fit_peak_with_background` + the background combo (row 251) |
| ✅ Done | L | L | Fit parameter constraints (POSITIVE/FIXED/QUOTED/…) | fitmanager.py:421-430 + leastsq.py (CFREE..CIGNORED) | `core::fitting::leastsq_constrained` + `Constraint` enum {Free, Positive, Quoted{min,max}, Fixed, Factor{ref,factor}, Delta{ref,delta}, Sum{ref,sum}, Ignored} port silx's full constraint-code path: free-set classification with `derivfactor` (QUOTED `B·cos` Jacobian scaling), per-step CPOSITIVE `abs` / CQUOTED `A+B·sin(arcsin+Δ)` reparametrisation, `get_parameters` dependent-relation expansion (Factor/Delta/Sum/Ignored), `noigno` model-input reduction, and the silx two-pass covariance (FIXED/IGNORED → zero row/col + value² diagonal) + `get_sigma_parameters` uncertainties (new `LeastSqResult.uncertainties`). The shared `chisq_alpha_beta_constrained` is reused by the fit and covariance passes. Unconstrained `leastsq` left byte-identical (sibling, shares `invert_matrix`). FitWidget wiring landed via row 251 (`fit_peak_constrained` + the per-parameter constraint editor, FREE/POSITIVE/FIXED subset exposed). 9 headless tests (all-free≡unconstrained, Fixed hold + 100% uncertainty, Positive enforce/recover, Quoted clamp/recover, Factor/Delta/Sum ties, bad-spec rejects). One deliberate deviation: a single constraint-expanded base evaluation for the forward difference (silx uses raw `model(*parameters)`), equal for clean in-domain inputs |
| ✅ Done | L | L | Fit multi-peak search | silx.math.fit.peaks (`seek`/`peak_search`) + fittheories `estimate_height_position_fwhm` + funs.c `sum_gauss` | Discovery primitive (`core::peaks`): `peak_search`/`peak_search_range` port silx C `seek` (2nd-derivative-of-Gaussian smoothing, significance state machine incl. doublet split; faithful incl. the init/loop gfactor off-by-one and the `data[0]=data[1]` quirk); `guess_fwhm` ports the strip-then-half-max width (floor 4). **Simultaneous fit now wired** (`core::fitting`): `multi_gaussian_model` ports `sum_gauss` (flat `(h,c,fwhm)` triples, no bg, `dhelp≤20` guard); `estimate_multi_gaussian` ports `estimate_height_position_fwhm` (peak_search seeds + `5·xspan/n` FWHM seed, ForcePeakPresence global-max fallback, a 4-iter preliminary constrained refine with centre quoted to ±½·search-fwhm width, then default-config final constraints height/FWHM Positive + centre Free); `fit_multi_gaussian` runs the full `leastsq_constrained` over all peaks at once. 12 headless tests (7 peaks + 5 multi-fit: model=Σ-singles, 2-peak/1-peak recovery, seed/constraint shape, empty/mismatch). FitWidget multi-peak UI is row 251 |
| ✅ Done | L | L | Fit non-peak theories (step up/down, slit, arctan) | fittheories.py THEORY + funs.c (`sum_stepdown`/`sum_stepup`/`sum_slit`), functions.pyx `atan_stepup` | `core::fitting` gains `stepdown_model`/`stepup_model`/`slit_model`/`atan_stepup_model` (erfc/erf edges byte-faithful to `funs.c`; `erf` via Abramowitz-Stegun 7.1.26, ≤1.5e-7, exact at 0) + estimators `estimate_step{up,down}`/`estimate_slit`/`estimate_atan_stepup` (silx edge-kernel convolution; slit centre from half-max crossings on a `min(y)` baseline). `PeakModel::{StepDown,StepUp,Slit,AtanStepUp}` flow through `IterativeFit`; `FitModelChoice` gains 4 iterative choices. silx has no exp *fit* theory; the Degree 2–5 Polynomial *fit* theories (distinct from the polynomial *background* of row 254) are ported in commit a476018 — see the fit-theory-completion progress note below. 12 headless tests; FitWidget UI GPU-unverified |
| ✅ Done | L | L | Standalone RadarView overview widget | tools/RadarView.py:139-300 | `RadarView` is a public, exported, standalone-usable widget (`RadarView::new`/`ui`, geometry unit-tested) AND embedded in `ImageView` with the full silx binding (data-extent + viewport sync, drag → `set_limits`). The prior "no standalone widget" note was backwards: the widget exists; what had been deferred (the live-plot binding) has since landed. Paint GPU-unverified |
| ✅ Done | L | L | Print preview dialog | PrintPreviewToolButton.py | `PrintPreview` (`print_preview.rs`): a detachable page editor that places the plot snapshot on an A4 page, movable + corner-resizable (keep-aspect by default, silx `_GraphicsResizeRectItem`), with Clear All / Remove / Zoom± / per-item keep-aspect / Save Page. The snapshot is any RGBA8 image (e.g. `render_plot_rgba`, now public); "Save Page" composes the arranged page to a PNG (`compose_page`) — the no-dep substitute for silx's `QPrinter` (its print-to-file path). System-printer submission stays with `PrintDialog` (silx `PrintAction`, row 470). 12 tests (resize keep-ratio/free/clamp, default placement, composite + alpha, page-PNG IHDR, add/remove/clear, zoom bounds, headless render path). |
| ✅ Done | L | L | ItemsSelectionDialog reuse | ItemsSelectionDialog.py; actions/fit.py:237-241 | Dialog gained the silx fit-tool capabilities it lacked: `set_available_kinds` (silx `setAvailableKinds`, offers only the requested kinds) + `SelectionMode::Single` (silx `setItemsSelectionMode(SingleSelection)`, ≤1 selected enforced by one owner). `examples/high_level_fit_widget.rs` now reuses it as a single-select curve+histogram picker that feeds `FitWidget::set_data` (silx `_initFit`→`_setFittedItem`). Capabilities/invariant unit-tested; example wiring GPU-unverified |
### Top candidate next waves (re-baselined 2026-06-04)
1. ~~**ROI on-canvas styling + selection feedback**~~ — **DONE (Wave 13, main, not
pushed).** Closed the top H cluster via the structural fix: `plot.rois` is now
`Vec<ManagedRoi>` (geometry + appearance) = single source of truth, the one live
`chrome::draw_rois` path resolves each ROI's color/name/selection/width/style/fill,
and `RoiManagerWidget` was reworked to a stateless editor over that one collection
(its second `Vec<ManagedRoi>` removed). Closed rows: per-instance color, name label,
selection highlight, `sigCurrentRoiChanged`, line style, line width, manager
default-color, fill toggle. *Still open (separate rows, not this wave):* ROI
right-click context menu. (Handle-symbol customization, edge-position
constraints, and EllipseROI orientation/rotation are since done.)
2. **Mask drawing tools + threshold/finite ops** (L, ~8 rows) — three H-priority
Missing draw tools (Rectangle/Polygon/Ellipse) + threshold UI, invert/transparency/
not-finite controls, Mask/Unmask toggle. Enum variants + `ImageMask` buffer +
`MaskHistory` already exist (`mask_tools.rs`); this wave wires interaction + UI on
done primitives. Biggest functional hole in the image workflow.
3. ~~**Structured item-click + hover signals**~~ — **DONE (Wave 14, main, not
pushed) — click, hover, AND marker-drag triad.** Added item-identified
`PlotEvent` variants `CurveClicked{handle,index,x,y,button}` /
`ImageClicked{handle,col,row,button}` / `ItemClicked{handle,button}` (marker/
scatter/shape) / `ItemHovered{handle,kind}`. `PlotWidget::show` routes this
frame's `PlotPointerEvent` through one owner `pick_topmost(pos) ->
(ItemHandle, PickResult)` (extends the prior `pick_topmost_item`), and the pure
`click_event_for_pick(handle, &PickResult, button)` maps each `PickResult`
variant to its event (unit-tested per boundary). `ItemHovered` was then
expanded to the full silx `prepareHoverSignal` payload
`{handle,kind,label,x,y,xpixel,ypixel,draggable}` (silx `selectable` omitted as
structurally constant). Finally the marker-drag triad was added: the single
owner `apply_interaction` (plot_widget.rs) surfaces `marker_drag_started`/
`marker_drag_finished` on `PlotResponse` at the grab and release sites, and
`show` emits `MarkerDragStarted`→`MarkerMoved`×N→`MarkerDragFinished` (silx
`beginDrag`/`drag`/`endDrag`), covered by a headless press→move→release test.
*Closed rows:* curveClicked/markerClicked/imageClicked (row 49) +
hover-with-metadata (row 50) + ItemsInteraction picker/state-machine + marker
drag-start/end triad + markerMoving/markerMoved split. **GPU
boundary:** the end-to-end `pick_item` and the hover metadata assembly
(`backend.marker`/`item_legend`) need a wgpu `RenderState` no test builds, so
on-hardware picking is GPU-unverified; the pure `click_event_for_pick`, the
headless marker-drag state-machine test, + the
existing pure pickers are the headlessly-tested seam.
4. **ROI stats dock widgets** (L, 2 H rows) — `CurvesROIWidget` + `ROIStatsWidget`;
the compute (`image_roi_stats`/`curve_roi_stats`) already exists, so it's widget
construction + binding. Consumes the selection feedback from wave 3.
5. **StackView 3D depth** (L, ~5 rows) — perspective selection, 3D transposition,
dimension labels, calibration, 3D-profile toolbar. Turns the 1D frame browser into
a true volume browser. Self-contained in `image_stack.rs`.
6. **ColormapDialog completeness** (M, ~3 rows, 1 H) — Stddev3/Percentile from raw
pixels (wire `Plot2D::get_image_pixels_raw` → `setHistogram`), histogram-distribution
overlay, matplotlib-dynamic catalog. Localized to `colormap_dialog.rs` + a small
Plot2D accessor.
The frozen as-of-sweep per-area tables below are kept only as the original reference;
the re-baselined view above supersedes their status columns.
## Progress log
### Wave 0 — cited detail
- ProfileWindow opens *beside* the main window (right → left → roomier edge,
vertically centred), restores previous position on reopen. silx
`ProfileManager.initProfileWindow`. Commit `3010677` (+4 boundary tests).
### Wave 1 — Colormap / Symbols / Mask model (parallel, worktree-isolated)
- **Colormap** (`c8b3794`..`1ab00de`): catalog → gray/reversed-gray/red/green/blue/
temperature/jet/hsv; `Normalization::Arcsinh`; `AutoscaleMode` MinMax/Stddev3/
Percentile(1,99); `Colormap::nan_color`; ColormapDialog wiring.
- **Symbols** (`22347ea`,`361a519`): silx symbol set — diamond, point, pixel,
vertical/horizontal line, tick{left,right,up,down}, caret{left,right,up,down};
char-code parsing matching silx.
- **Mask model** (`58e1416`..`f513506`): multi-level u8 buffer + level selector;
rectangle/polygon/disk/ellipse fills; threshold below/between/above; invert;
bounded undo/redo.
- Gate: clippy `--workspace` clean, **185 tests pass** (+41), doctests ok.
- **Deferred follow-ups** (cross-cluster wiring): arcsinh branch in `image.wgsl`;
ColormapDialog Stddev3/Percentile need Plot2D raw-pixel access;
mask on-plot drawing (Plot2D wiring) + Bresenham `draw_line` + mask file I/O.
(Heart glyph SDF — done: `Symbol::Heart` cardioid SDF in `markers.wgsl`.)
### Wave 2 — Image render / ROI / Interaction / Complex view (parallel, worktree-isolated)
- **Image render** (`0494dc8`,`c6344d1`,`595c68e`): arcsinh image normalization
(shader code-4 branch + `Normalization::Arcsinh`); `InterpolationMode`
Nearest(default)/Linear (manual bilinear on scalar data before colormap, since
R32Float is non-filterable); `AggregationMode` None/Max/Mean/Min + `aggregate_blocks()`
(NaN-ignoring, remainder dropped, scale scaled by block — silx ImageDataAggregated).
- **ROI** (`bbd28ed`,`b909dbe`,`26e3fdc`): `Roi::contains()` for every variant; new
Cross/Circle/Ellipse variants; chrome draws ROI color/name label/selected highlight;
`RoiManagerWidget` per-ROI color/name, current-ROI tracking, add buttons.
- **Interaction** (`3030edd`,`4efac2d`,`122f7f1`): log-aware pan/zoom (silx `panzoom`);
limits-history undo/redo stack (silx `LimitsHistory`); arrow-key pan (silx
`PanWithArrowKeysAction`).
- **Complex view** (`ee3d82e`): `ComplexImageView` composite — ComplexMode
Absolute/Phase/Real/Imaginary/SquareAmplitude/Log10Amplitude/AmplitudePhase, phase
hsv LUT, amplitude×phase compositing.
- Gate: clippy `--workspace` clean, **239 tests pass** (+54), doctests ok.
- **Integration fix** (`3430ef5`): the complex-view cluster predated Wave 1's
`Colormap::nan_color`, so its raw `Colormap {}` literal stopped compiling once both
waves landed. Rebuilt `phase_colormap()` through `Colormap::new` + functional-update
so future field additions no longer break the call site. Root cause: worktrees were
cut from session-start HEAD, not current `main` — later waves must
`git checkout -b waveN/<cluster> main` to inherit prior waves.
- **Deferred follow-ups** (cross-cluster wiring): interpolation/aggregation toolbar
toggles; ROI creation-mode toolbar + on-plot draw; limits-history undo/redo buttons;
ComplexImageView mode selector wired into a toolbar/menu (widget is self-contained).
### Wave 3 — ROI stats/styling / ColorBar+Alpha / Stats engine / ImageStack (parallel, worktree-isolated)
- **ROI** (`79822ac`,`9f17d0d`,`e112b63`,`b48175f`): handle geometry (`Roi::handles()` →
RoiHandle{pos,kind}, `Roi::translate`, pure — no mouse wiring); ArcROI (annular sector,
`rem_euclid` angle wrap) + BandROI (rotatable band) variants with `contains()`/handles/
chrome draw; per-ROI line width/style(Solid/Dashed/Dotted, manual dash segments)/fill on
ManagedRoi+chrome; ROI statistics module `roi_stats.rs` (`image_roi_stats`/`curve_roi_stats`
— min/max/mean/sum/integral, NaN-skipping; silx ROIStats + CurvesROIWidget raw-count).
Adding the two enum variants required 2 match arms in `high_level.rs` roi_description and
`examples/roi.rs` (non-`#[non_exhaustive]` enum obligation, not new hub logic).
- **ColorBar/Alpha** (`c25a273`,`16c9c59`,`88877a9`): standalone `ColorBarWidget`
(256-step gradient, vertical+horizontal, nice/decade ticks via silx `ticklayout.py`,
`%g` end labels, rotated legend); `AlphaSlider` (0..=255 ↔ f32 alpha); Colormap utilities
— custom-LUT (`with_lut` + Nx3/Nx4 resample to 256), `set_autoscale_percentiles`,
`editable` flag guarding mutators (public field — silx `_editable` is public; required so
FRU `..Colormap::new()` in complex_image_view still compiles), copy/set_from.
- **Stats** (`429f45b`,`4ad46dc`,`28a1574`): pure engine `core/stats.rs` — min/max/delta/
mean/sum/COM/argmin-argmax-coords/integral, `StatScope::{All,OnLimits}` viewport clipping,
curve-ROI x-range scope (silx `stats.py`); `StatsWidget` table (per-item rows, stat columns,
auto/manual update, `%.7g` formatter); `PositionInfo` readout bar with pluggable converters
(default polar). Built standalone — `high_level.rs`'s minimal ValueStats not yet delegated.
- **ImageStack** (`7ce78b3`): self-contained composite (mirrors ComplexImageView) browsing an
in-memory `Vec<Option<Frame>>` — slider + first/prev/next/last, frame table with visibility
toggles, waiting overlay for empty/hidden/size-mismatched slots; pure `FrameNav` core (clamp/
step/visibility/labels) drives 22 tests. Lazy URL/HDF5 + prefetch out of scope (silx.io).
- Gate: clippy `--workspace` clean, **381 tests pass** (+142), doctests ok.
- **Deferred follow-ups** (need `high_level.rs`/`interaction.rs`, an actions wave): on-plot
ROI editing/creation incl. Arc/Band per-handle drag + `Roi::move_edge` for the new kinds
(no-op now); ROITable rich-stats columns wiring `image_roi_stats`; ColorBarWidget into
ImageView/ScatterView/chrome; NamedItem/ActiveImage AlphaSlider (needs plot model);
StatsWidget/PositionInfo bound to live items+cursor + PositionInfo snapping; delegating
`high_level.rs` ValueStats to `core::stats`; ItemsSelectionDialog; ImageStack toolbar entry;
promoting `RoiLineStyle` to the lib.rs convenience re-export.
### Wave 4 — Interaction draw-modes / Axis+datetime / Image-Marker-Shape items / Mask IO (parallel, worktree-isolated)
- **Interaction** (`c6e6fe3`,`60fe2af`,`9bacd18`,`2a059ce`,`5383c1b`): float32-safe
pan/zoom clamping (silx `FLOAT32_SAFE_MIN/MAX`); new `PlotPointerEvent` (click/double-click/
hover with button + data+pixel pos + limits tuples — kept distinct from `high_level`'s
`PlotEvent`); `CursorShape` from draggable-edge detection; `DrawState` state machine
(Rectangle/Ellipse/Line/H-V-Line/Polygon-with-snap-close/FreeHand → `DrawEvent::{InProgress,
Finished}`, silx PlotInteraction Select*); selection-area fill modes (hatch/solid/none) +
draw-mode rubber-band overlay painted in `plot_widget.rs` (not chrome). NaN bound clamps to
lower (documented; keeps range finite/ordered, unlike numpy.clip).
- **Axis** (`9961a07`,`5052b4e`,`3957d98`,`da81279`,`7480498`,`ba4ef49`): `core/dtime_ticks.rs`
(UTC-only DtUnit/bestUnit/calcTicks via days-from-civil, no chrono dep — silx
`dtime_ticklayout.py`); per-axis autoscale + `DataRange` reset-zoom; `DataMargins` per-side
ratios (log-aware); `axes_displayed` flag + `DirtyState{Clean,Overlay,Full}` lifecycle
(state only); axis-label fallback to active-curve label; `grid_color` split from foreground.
All additive — Plot is built only via `Plot::new`, so existing construction is untouched.
- **Items** (`dc18c84`,`3b53a6a`,`5d2836c`,`cf8c4fe`,`21c077d`): `ScalarMask` (per-pixel
validity → NaN via the existing `nan_color` path; standalone since `ImageData` lives in
`render/gpu_image.rs`); marker `is_draggable` + `MarkerConstraint`(H/V/custom, pure
`apply_constraint`) + `drag`; `TextAnchor` alignment offset; `Shape::is_overlay`; infinite
`Line` item with `clipped_segment(bounds)` (silx shape.py `Line.__updatePoints`).
- **Mask** (`b062e32`,`bc19767`,`233ccac`,`94f799a`): Bresenham pencil `draw_line` (width +
degenerate, silx `shapes.draw_line`); hand-written NumPy `.npy` save/load with crop/pad +
resize flag (no external crate); `mask_not_finite`; new `scatter_mask.rs` `ScatterMaskWidget`
(per-point disk/polygon/rect masking, point-in-polygon from `Polygon.is_inside`, shared
multi-level + undo/redo).
- Gate: clippy `--workspace` clean, **510 tests pass** (+129), doctests ok.
- **Deferred follow-ups** (actions/render wave): chrome wiring of `axes_displayed`/`grid_color`/
datetime tick labels; render of `Line`/marker-drag/`Shape::is_overlay` data layer; `ScalarMask`
applied before image upload; `DrawEvent::Finished` → ROI/mask creation; overlay-only replot
short-circuit; per-pixel scalar alpha (needs shader); timezone for datetime axis; on-plot mask
draw (plot→data coords) + mask colormap overlay + active-item sync; EDF/TIFF/HDF5/msk codecs.
### Wave 5 — Iterative fit / Scatter-viz algorithms / Export formats / RadarView (parallel, worktree-isolated)
- **Fit** (`f293ca1`,`5b84bff`): unconstrained Levenberg-Marquardt `leastsq` (forward numerical
Jacobian, flambda damping, covariance via Gauss-Jordan inverse, reduced-χ² — silx
`math/fit/leastsq.py`); peak models Gaussian/GaussianArea/Lorentzian/PseudoVoigt with analytical
seeds (`fittheories.py`/`funs.c`); `fit_in_range(xmin,xmax)`; FitWidget `FitModelChoice` +
results table (name·value·±error·reduced-χ²). Legacy estimate/linear fit kept (additive).
- **Scatter-viz** (`c8f0544`): pure `core/scatter_viz.rs` — Bowyer-Watson Delaunay; SOLID
(per-vertex-colored `Triangles`); IRREGULAR_GRID (barycentric raster to image); regular-grid
auto-detection + `GridMajorOrder`; `BinnedStatistic` mean/count/sum; per-point alpha. Render/
picking wiring deferred. Note: IRREGULAR here = Delaunay-raster, not silx's quadrilateral mesh.
- **Export** (`5499da2`,`7abefe6`,`b225b68`,`0e9a258`): PPM (P6); SVG (base64-PNG `<image>` wrap);
hand-written uncompressed baseline TIFF with DPI XResolution/YResolution tags; `SaveFormat` enum
+ extension auto-detect + `save_graph_with_format` dispatch (silx `saveGraph`/`PlotImageFile`).
All encoders pure `encode_*(rgba,w,h,..)`; existing `save_graph`/PNG signature unchanged.
- **RadarView** (`5d8bede`): self-contained `widget/radar_view.rs` overview — full-extent box +
draggable viewport rect, aspect-fit data↔widget mapping + inverse, clamp-to-extent, hit-test,
emits new limits on drag (silx `tools/RadarView.py`). Live-plot pan wiring deferred.
- Gate: clippy `--workspace` clean, **584 tests pass** (+74), doctests ok.
- **Deferred follow-ups** (actions/render wave): wire scatter-viz outputs into the GPU triangle/
image path + per-mode picking; FitWidget→live curve; RadarView→Plot2D pan + auto-extent; true
vector SVG (record draw ops); JPEG/EPS/PDF; LM constraints + strip background + multi-peak search.
### Wave 6A — Chrome / input plumbing (model + primitives the HL wiring consumes)
First sub-wave of the chrome/actions effort. Single file-disjoint cluster owning `chrome.rs` +
`plot_widget.rs` + `interaction.rs` + `core/plot.rs` (the others would collide on `plot_widget.rs`,
which orchestrates chrome render + interaction together). Implemented by a worktree agent, then
each item adversarially verified (silx-fidelity + additive-only + gate); 2 fixes applied at source.
- **Datetime ticks** (`5ee3630`,`5477f69`): X-axis `TickMode` (Numeric/TimeSeries) on `Plot`; chrome
`axis_ticks_with_mode` routes a TimeSeries linear X axis through `dtime_ticks::calc_ticks`/
`format_ticks` (silx `XAxis.setTickMode`+`NiceDateLocator`). **Fidelity fix:** silx implements
`setTickMode` on `XAxis` only (`YAxis` raises `NotImplementedError`, no `setYAxisTimeSeries`), so
the time-series mode is X-only by API shape — dropped the symmetric `y_tick_mode` the first pass added.
- **Axes-hidden gutters** (`ac84ca0`): `ChromeRequest.axes_hidden` collapses every axis gutter to
zero and skips frame/ticks/labels when `!axes_displayed()` (silx `setAxesDisplayed(False)`); colorbar
strip still reserved (separate widget). Default false = no behavior change.
- **Infinite Line render** (`41eebb1`): `Plot.lines: Vec<Line>` + `add_line`; chrome `draw_lines`
clips each via the tested `Line::clipped_segment` and paints the visible segment, called after
`draw_shapes` (silx `items/shape.py` Line `__updatePoints`).
- **PlotPointerEvent / DrawEvent / mode surfacing** (`7d23f37`,`84289c7`): `apply_interaction` emits
`PlotPointerEvent` (Clicked/DoubleClicked/Moved, pixel→data via transform; silx `prepareMouseSignal`)
via additive `PlotResponse.pointer_event`; `draw_event` mirrors the latest draw event onto the plain
show path; read-only `interaction_mode`. **Test fix:** added double-click (explicit headless
timestamps) + right/middle-button coverage.
- Item 5 (ROI-edge cursor) was already complete on `main`; no no-op commit. Gate: clippy clean,
**598 tests pass** (+14), doctests ok.
- **UNFIXED / deferred this sub-wave** (external dep or decision-gated): marker drag + marker-drag
cursor (marker list lives in `Plot2D`, not core `Plot` — needs ownership decision); `Shape::is_overlay`
data-layer (no under-chrome render path); `DirtyState::Overlay` short-circuit (render-loop wave);
high-level mouseClicked/markerClicked/curveClicked **consumption** (lands in `high_level.rs`, 6B).
### Wave 6B-1 — HL view wiring (ImageView side) + standalone widget files (2 parallel clusters)
Two file-disjoint worktree clusters; per-item adversarial verify; 1 fix applied at source.
- **standalone-widgets** (own files, disjoint from `high_level.rs`): ColormapDialog NaN-color picker
(`503bc34`) + percentile-fields regression test (`b70acc9`); ComplexImageView `show_mode_toolbar`
mode selector (`3837a68`, silx `_ComplexDataToolButton`); ImageStack public `show_toolbar` +
`NavAction` first/prev/next/last/goto (`9600976`); mask `.npy` codec moved to one owner in
`render::save` (`encode_mask_npy`/`decode_mask_npy`, numpy v1.0 uint8) + path-string save/load,
`mask_tools` delegates (`3054459`); NEW `ItemsSelectionDialog` — per-kind filter + grouped
selection, genuinely missing vs the flat example (`e21fdff`). Interactive controls factored into
pure `*_ui` helpers for headless-egui tests.
- **hl-views** (`high_level.rs` sole writer): ColorBar column synced to colormap (`68b8eb7`);
AlphaSlider→active image (`9d8f890`); interpolation/aggregation selectors (`700edf0`); PositionInfo
bound to the live cursor via 6A's `PlotPointerEvent` (`50b0f0e`,`9f6eb92` test fix adds
Clicked/DoubleClicked coverage); RadarView overview→pan/zoom (`c2f35ee`); profile tool (`29e645a`);
optional pre-upload `ScalarMask`→NaN on `Plot2D` images (`8ef46c2`); `ValueStats` delegated to
`core::stats` single source (`d2468ab`).
- Gate: clippy `--workspace` clean, **633 tests pass** (+35), doctests ok. All 14 items accepted by
review except the PositionInfo test gap (fixed). No items deferred within this sub-wave.
- **Deferred to 6B-2** (need `high_level.rs` + `plot_widget.rs`/`interaction.rs` together, or raw pixels):
ScatterView ColorBar/scatter-viz-dispatch/mask-panel; StatsWidget + FitWidget binding; Stddev3/
Percentile autoscale from raw pixels (`Plot2D::get_image_pixels_raw`); mask mode-vs-pan
(`PlotInteractionMode::MaskDraw`) + pencil-draw routing.
### Wave 6B-2 — HL view wiring (ScatterView side) + stats/fit binding + raw-pixel autoscale
Single `high_level.rs`-sole-writer cluster (6 items); per-item adversarial verify (all 6 accepted, 0 issues).
- ScatterView value ColorBar synced to colormap limits (`b03d76c`, silx `ScatterView.py:83-88`).
- ScatterView visualization-mode dispatch: new `ScatterVisualization` enum (silx
`ScatterVisualizationMixIn`); POINTS = existing marker cloud (default, unchanged); IRREGULAR_GRID /
REGULAR_GRID / BINNED_STATISTIC convert retained `(x,y,value)` via the Wave-5 `core::scatter_viz`
primitives → `GridImage` → image path; `rebuild_visualization` is the single owner of the
scatter-vs-grid item handle (`f6ee396`, silx `scatter.py:402-680`). SOLID deferred (shader).
- ScatterView mask-tools panel: embeds a `ScatterMaskWidget` sized to point count; level/clear/invert/
undo/redo/threshold/disk/rect/polygon selections applied to the scatter (`2440965`, silx
`ScatterView.py:116-122`).
- `StatsWidget` bound to live items via new `RetainedItemData` on `ItemRecord` (populated by the typed
curve/image spec entry points); `feed_active_stats` recomputes from the active item through the one
`core::stats` engine (`c2d1fa3`, silx `StatsWidget`).
- `FitWidget` bound to a live curve: `set_fit_target`/`set_active_fit_target` pull the curve's retained
`(x,y)` and call `FitWidget::set_data`; images/non-curves rejected (`43cc1df`, silx `FitWidget`).
- Raw-pixel autoscale: new `Plot2D::get_image_pixels_raw`; `autoscale_active_image` computes Stddev3
(mean ± 3·std) / Percentile bounds NaN-ignoring via `AutoscaleMode` and re-uploads the image colormap;
`colormap_dialog.rs` untouched (`1be8f53`, silx `ColormapDialog.py:450-480`).
- Gate: clippy `--workspace` clean, **647 tests pass** (+14), doctests ok. `lib.rs` gained one additive
re-export (`ScatterVisualization`). No foreign-file edits.
- Integration note: an agent's first Item-1 edits leaked into the MAIN checkout via a bare repo-root
path (same failure mode as 6B-1); the agent reverted with `git checkout -- <file>` and redid the work
in the worktree, but its recovery left a stray `git reset` that fast-forwarded `main` to the branch
tip. Verified safe before recording: `main^{tree}` byte-identical to the adversarially-verified
`wave6/hl-scatter-stats` tip, 6 commits linear on `8a0ee44`, diff touches only `high_level.rs`+`lib.rs`,
full-workspace gate re-run green on the main checkout.
- **Deferred to 6C** (need `plot_widget.rs`+`interaction.rs`+a toolbar toggle): mask mode-vs-pan
(`PlotInteractionMode::MaskDraw`) + on-plot pencil-draw routing. **Deferred (shader):** SOLID scatter
visualization, per-point scatter alpha.
### Wave 6C-1 — HL actions module + toolbar wiring (rfd Save + arboard Copy)
New `src/widget/actions/{mod,control,io}.rs` mirroring silx `plot/actions/` *behavior* (not the Qt
QAction hierarchy — immediate-mode egui needs none). Added crates `rfd` (native Save dialog) + `arboard`
(clipboard) — user-approved. Single `high_level.rs`+`actions/` sole-writer cluster (7 commits + 4
review-fix commits); per-item adversarial verify.
- ShowAxis toggle (`set_axes_displayed`, silx `ShowAxisAction`); ColorBar show/hide on ImageView+
ScatterView (silx `ColorBarAction`); CurveStyle cycle of the active curve's `LineStyle` (silx
`CurveStyleAction` — divergence: silx cycles plot-wide line/points booleans, accepted); ZoomIn/ZoomOut
about the view center at silx's 1.1 step (silx `ZoomIn/OutAction`); ZoomBack popping `Plot::
limits_history` with reset-zoom fallback (silx `ZoomBackAction`).
- Save (silx `SaveAction`): `rfd` native dialog → figure PNG (existing `save_graph`, GPU-readback shim)
or active-curve CSV; `SaveTarget` extension→format + `curve_to_csv` are pure/tested. Copy (silx
`CopyAction`): figure RGBA→`arboard::ImageData` via pure `decode_png_to_rgba`/`rgba_to_clipboard_image`,
clipboard call is a shim.
- CurveStyle is backed by a new `RetainedItemData::Curve` holding the full `CurveData` (so the cycle
preserves color/symbol/width/errors/fill); `lib.rs` gained `actions` + `SaveTarget`/`curve_to_csv`
re-exports.
- Adversarial review found 4 fix-needed; all fixed at source on `main` as one-commit-per-finding:
(A) colorbar-toggle observable-effect test (`e748a7d`); (B) toolbar zoom must NOT push limits history —
silx pushes only from the drag-zoom interaction, removed the `apply_zoom` push (`5664ac8`); (C) zoom on
a log axis must scale in `log10` space — added silx `scale1DRange`'s log branch + per-axis log threading
+ geometric log-midpoint, float32 clamp left to its separate item (`f2aeeae`); (D) curve CSV must emit
true C/numpy `%.18e` (`e+00`) not Rust's `e0`, with a non-tautological test cross-checked vs Python
(`08d9fe8`).
- Integration note: the agent's leak-check passed once after the first commit but later edits still leaked
an uncommitted partial mirror into the MAIN checkout (missing the final `lib.rs` lines). The committed
branch was authoritative; `reset --hard wave6/hl-actions` discarded the leak and fast-forwarded `main`
to the verified tip (tree-identical), then the 4 fixes landed on top.
- Gate: clippy `--workspace` clean, **665 tests pass** (+18), doctests ok.
- **Deferred to 6C-2** (need `plot_widget.rs`+`interaction.rs`+`actions/mode.rs`): mask mode-vs-pan
(`PlotInteractionMode::MaskDraw`) + on-plot pencil-draw routing. **Deferred to Wave 7:** Print action,
median-filter + pixel-histogram actions, SVG/PPM/TIFF figure save (needs a public RenderState/format
save path), per-axis X-only/Y-only autoscale toggles.
### Wave 6C-2 — Mask draw mode + on-plot pencil routing (final Wave-6 sub-wave)
Single worktree-isolated cluster owning `plot_widget.rs` + `actions/mode.rs` (NEW) + `high_level.rs`
(3 commits); per-item adversarial verify. BASE `79583e3`.
- `PlotInteractionMode::MaskDraw` variant — silx's dedicated pencil draw interaction, distinct from
pan/zoom: primary-drag is reserved for mask painting. `apply_interaction`'s `== Pan`/`== Zoom`
comparisons already suppress primary-pan and box-zoom for the new variant; the one primary-drag path
NOT gated by an `== mode` check (ROI-edge grab, was `mode != Pan`) became `mode_grabs_roi_edge(mode)`
excluding Pan AND MaskDraw, applied at the grab site and the hover resize-cursor site. Secondary-drag
pan + wheel zoom intentionally left intact (matches silx draw interaction). NO enum-leak: every
`PlotInteractionMode` use is an `== Variant` comparison (no exhaustive match anywhere), so the variant
forced zero match-arm edits — clippy `--all-targets` (compiles examples) confirms.
- `actions/mode.rs` (NEW): `zoom_mode`/`pan_mode`/`mask_draw_mode`/`select_mode` — thin one-transition
setters over `PlotWidget::set_interaction_mode`, mirroring silx `actions/mode.py` `ZoomModeAction`
(`mode.py:45`) / `PanModeAction` (`mode.py:108`). silx has no `MaskModeAction` (`MaskToolsWidget` owns
its pencil draw mode), so `mask_draw_mode` is a port-specific setter grouped with the others. `lib.rs`
re-exports the four; `actions/mod.rs` gains `pub mod mode`.
- ImageView mask painting: embedded a `MaskToolsWidget` (resized to the active image on `set_image`; a
shape change resets undo history = silx `reset(shape)`). `ImageView::show` routes the captured
`PlotResponse` to the EXISTING Wave-4 `handle_interaction` strictly gated on `MaskDraw`
(`image_view_should_paint_mask` — pan/zoom/select never paint); on change `upload_image` re-uploads with
the painted level buffer → `ScalarMask` (`scalar_mask_from_level_buffer`, oversize lazily clipped to the
image shape) applied as NaN-holes — the identical 6B-1 pre-upload representation (silx `getValueData`,
`items/image.py`). Toolbar `set_mask_draw` toggle enters/leaves `MaskDraw` via `actions::mode` (Pencil
tool on entry, None+Zoom on exit) with pencil/eraser/brush-size/clear controls while active.
- Adversarial review found 1 fix-needed, fixed at source on `main` as one commit (`31815f7`): the reused
Wave-4 `handle_interaction` painted an inline circular brush at the current cell each frame with no
inter-frame memory, so a fast pencil/eraser drag left gaps — unlike silx (`MaskToolsWidget.py:848-876`)
tracking `_lastPencilPos` + `updateLine`. **Structural fix** (remove the dual painting impl): route
on-plot pencil/eraser through the existing faithful `update_line`/`update_disk` primitives with a
`last_pencil_pos` anchor — `paint_pencil_point` interpolates a thick Bresenham line from the previous
sample (silx `updateLine`, width = brush size) then stamps a disk of radius `brush_size/2` (silx
`updateDisk`); `end_pencil_stroke` clears the anchor on release / new click (silx resets `_lastPencilPos`
on `drawingFinished`); `reset_geometry` clears it so a stroke never interpolates across a geometry
change. Two silx-matching side-effects of using the shared primitives: the brush is now radius
`brush_size/2` (dots match the line thickness, was 2×), and the eraser clears only the current mask
level (was any level), matching `updatePoints(mask=False)`.
- Gate: clippy `-p siplot --all-targets` clean (== `--workspace`, sole member), **672 tests pass** (+7:
5 feature tests + 2 fix regression tests), doctests ok.
- **Deferred (shader, unverifiable without GPU):** mask colormap GPU overlay — the port renders masked
pixels as NaN-holes (scalar pipeline `nan_color`) instead. Per-point scatter on-plot pencil draw (needs
`scatter_mask.rs`, a non-owned file; `ScatterView::show_mask_tools` already covers whole-mask + threshold
ops). **Wave 6 complete.** **Deferred to Wave 7:** Print action, median-filter + pixel-histogram actions,
SVG/PPM/TIFF figure save (needs a public RenderState/format save path), per-axis X/Y-only autoscale.
### Wave 7A — Figure output: SVG/PPM/TIFF save + Print action
Single worktree-isolated cluster owning `src/core/backend.rs` + `src/render/backend_wgpu.rs` + `high_level.rs`
+ `Cargo.toml` (3 commits); per-item adversarial verify. BASE `2de55f6`. Recon found the figure-save
primitives already complete — this wave is wiring + one new dep.
- SVG/PPM/TIFF figure save (NO new dep): the four `encode_{png,ppm,svg,tiff}` encoders and
`render::save::save_graph_with_format` (one GPU readback → centralized format match) already existed &
were tested; only the UI entry point dropped non-PNG. Added `Backend::save_graph_with_format` (trait +
the sole `WgpuBackend` impl — `rg` confirmed one impl, no headless backend) + a `PlotWidget` passthrough;
`save_to_path` now routes every `SaveTarget::Figure(fmt)` through it at `DEFAULT_SAVE_DPI=96` (PNG stays
byte-identical, same `encode_png`); `save_dialog` gained PPM/SVG/TIFF rfd filters. Faithful to silx
`SaveAction._saveSnapshot → plot.saveGraph(filename, fileFormat)` (actions/io.py:225-242). SVG remains the
intentionally raster-wrapped encoder (true vector export still deferred). Pure test: the extension→
`SaveFormat` dispatch table (png/ppm/svg/tif/tiff→Figure, csv→CurveCsv, pdf/noext→None) asserted without
a GPU; the readback + write are shims.
- Print action (user-approved `printers` crate, v2.3.0 — verified to build on macOS: wraps CUPS/winspool).
`print_graph` mirrors silx `PrintAction.printPlot` (actions/io.py:809-846): silx renders the plot to a PNG
(`_plotAsPNG`) and draws that bitmap onto the printer via `QPainter`/`QPrinter` (RASTER, not vector). Here
the figure is rasterized to a process-unique temp PNG via the existing `save_graph`, then submitted to the
default printer (`get_default_printer` → `print_file`); returns `Ok(false)` with no default printer.
`ToolbarIcon::Print` + `ToolbarResponse.print` + toolbar button (Save-button pattern, result ignored).
Pure test: the temp-path naming (`print_temp_png_path`); the GPU readback + printer submit are untested
platform shims. `printers::PrintersError` has no `Display`, so its `.message` field is used in the error
string; printer-submit errors reuse `SaveError::Readback` (internal, toolbar ignores it) to avoid a
`SaveError` enum-variant fanout. Print preview / `QPrintDialog` printer-settings UI intentionally omitted.
- Both adversarial reviewers returned `accept`; the integration diff was ALSO reviewed by hand before the
fast-forward (the verify reviewers had been handed absolute worktree paths as the `git diff` pathspec,
which can silently empty the diff — fixed for 7B by reporting repo-relative paths). No findings.
- Gate: clippy `-p siplot --all-targets` clean (printers compiled), **674 tests pass** (+2), doctests ok.
- **Deferred:** true VECTOR SVG (re-emit geometry); PDF/PS/EPS/JPEG (matplotlib-only, `from_extension`
rejects); print preview / printer-settings dialog (no egui `QPrintDialog`). **Wave 7B next:** per-axis
X/Y autoscale toggles + the widget-reset flag-respect fix; median filter (crate) + pixel-intensity
histogram.
### Wave 7B — Per-axis X/Y autoscale toggles + widget-reset structural fix
Single worktree cluster owning `high_level.rs` + `actions/control.rs` (2 commits); per-item adversarial
verify (both accept, `structuralNotPatch` true). BASE `ec1b854`. `core/plot.rs` untouched — the model was
already complete.
- **Structural fix (the real bug):** the per-axis autoscale flags `x/y/y2_autoscale` (all default `true`)
were honored by the model owner `Plot::reset_zoom_to_data_range` (tested) but the WIDGET reset path
(`apply_limits_from_data_bounds`) **bypassed** them — it called `set_limits_internal` writing both axes
unconditionally, so the flags were dead at the widget level (two reset paths, one flag-blind). Fixed by
the PREFERRED single-owner delegation: a new pure `data_range_from_bounds(DataBounds)→DataRange` (per-axis
`as_non_degenerate`, `None` for a dataless axis) feeds `reset_zoom_to_data_range`, with the `LimitsChanged`
event preserved (`limits_snapshot` + `push_limits_changed_if`). Verified safe: `WgpuBackend::set_limits`
only assigns `plot.limits`/`plot.y2` — the same two fields the model owner writes — and the event is the
only widget-side bookkeeping, so delegation regresses nothing. Default case (all flags on + zero margins)
is byte-identical to the old path; the only behavior change is the bug fix (off-axes now pinned) plus
non-zero `data_margins` now applied on reset (matches silx `_forceResetZoom`, zero by default). The flag/
log-force/margins logic now lives in exactly ONE place. Faithful to silx `PlotWidget.resetZoom`
(PlotWidget.py:3352-3403). Pure tests: x-off/y-on keeps X refits Y; converse; all-off no-op; degenerate
padding.
- Per-axis X/Y autoscale toolbar toggles: `ToolbarIcon::AutoscaleX/AutoscaleY` (+ `draw_autoscale_icon`
glyph; the only exhaustive `ToolbarIcon` match got the two arms, `size()` uses a wildcard), checkable
buttons (selected = `plot().x_autoscale()`), `ToolbarResponse.autoscale_x_changed/autoscale_y_changed`,
+ pure `toggle_x_autoscale`/`toggle_y_autoscale` in `control.rs`. Mirrors silx `XAxisAutoScaleAction`/
`YAxisAutoScaleAction` (control.py:172-223) `_actionTriggered(checked)`: `setAutoScale(checked)` then
reset-zoom ONLY on enable (disable pins the current view); the reset routes through the now-flag-aware
widget path. Pure tests: enable refits only the enabled axis; disable does not reset.
- Gate: clippy `-p siplot --all-targets` clean, **680 tests pass** (+6), doctests ok (no doctest changed).
- **Deferred:** Y2 (right-axis) autoscale toolbar button (model flag already honored; a third button would
bloat the toolbar row). **Wave 7C next:** median filter (`medians` crate) + pixel-intensity histogram.
### Wave 7C — Median filter + pixel-intensity histogram (FINAL substantive Wave-7 sub-wave)
Single worktree-isolated cluster owning new `src/widget/actions/analysis.rs` + `high_level.rs` +
`actions/mod.rs` + `Cargo.toml` (3 commits); per-item adversarial verify (both `accept`, no issues). BASE
`3d27137`. The pure compute is the tested deliverable; the kernel/conditional popup, the histogram window,
and the GPU image re-upload are UI/GPU shims. Agent read silx `silx.math.medianfilter` (`.pyx` defaults +
the C++ `median<T>()` in `include/median_filter.hpp`) and `actions/{medfilt,histogram}.py` for ground truth.
- **Median filter** (user-approved `medians` v3.0.12, build-verified on macOS): pure `median_filter_2d(data,
w, h, kernel_h, kernel_w, conditional)` + `median_filter_1d` (= 2D with kernel `(kw, 1)`, silx
`MedianFilter1DAction`) in `analysis.rs`. Faithful to silx `medfilt2d` for the **default `mode='nearest'`**
that `MedianFilterAction` relies on (calls `medfilt2d` with no `mode`): out-of-bounds window indices clamp
to `[0, dim-1]`; window median = `sorted[n/2]` (`std::nth_element` semantics — the *higher* of two
centrals, not an average). Odd NaN-free windows use the crate's `Medianf64::medf_unchecked`; even/NaN-
reduced windows use `select_nth_unstable_by(n/2)` because the crate's even path AVERAGES the two centrals
(would diverge from silx) — pinned by `nan_ignored_even_count_takes_higher_central` ({10,20}→20). NaN
ignored in-window; all-NaN window→NaN. `conditional`: replace center only if it equals the window min or
max (a NaN center is never an extremum, so it propagates). `apply_median_filter`/`_1d` (on `PlotWidget`)
read `RetainedItemData::Image` and re-upload in place via the existing `update_image_spec` (silx
`addImage(replace=True)`) — no non-owned file. `MedianFilterParams` (kernel_width odd, conditional;
default 3×3) + `Plot2D::show_median_filter`/`show_median_filter_toolbar` popup (silx `MedianFilterDialog`;
spinbox min 1 step 2 enforced by `force_odd`). 11 unit tests (salt-spike removal, sorted-middle interior,
NEAREST corner clamp, constant image, conditional keep-vs-fix, 1D≡2D-(k,1), 1×1 identity, NaN cases).
- **Pixel-intensity histogram**: pure `pixel_intensity_histogram(pixels, n_bins) → Option<PixelHistogram>`
(edges/counts/min/max/mean/std/sum/n_bins) in `analysis.rs`. Faithful to silx `PixelIntensitiesHistoAction`
/ `HistogramWidget._updateFromItem` (histogram.py:226-419): bins = `min(1024, floor(sqrt(finite_count)))`
then `max(2, …)` (a `Some(n)` override also floored at 2); range = finite `(min,max)` (NaN/±inf excluded
from range, counts AND stats); `last_bin_closed=True` (max lands in the last bin, interior `v` →
`floor((v-min)·n_bins/range)`); degenerate `min==max` range enlarged exactly as silx (`(-0.01,0.01)` at 0,
else sorted `(v·0.99, v·1.01)`); stats = `nanmean`/population `nanstd`(ddof=0)/`nansum`; `None` on all-not-
finite (silx `reset`). `active_image_histogram` (on `PlotWidget`) reuses `get_image_pixels_raw`;
`Plot2D::show_pixel_histogram`/`_toolbar` is a CPU/egui-painter shim (bars in silx `#66aad7` + stats +
editable bin count, NO second GPU `Plot1D`). 9 unit tests (sqrt-floor bins, exact last-bin-closed counts,
stats, non-finite exclusion, degenerate ranges, all-not-finite None, bins-floored-at-2, interior floor).
- **Self-review fix (caught in my own integration diff, not by the reviewers — 1 commit `d0b86a9`):** the
branch added `ToolbarResponse.median_filter_applied`/`pixel_histogram_open` but **nothing ever set them** —
the buttons live on `Plot2D`'s standalone `show_*_toolbar` methods (own bool returns), while the field-
producing `show_toolbar_controls` is on `PlotWidget` (shared with curve-only `Plot1D`, which silx never
shows image actions on). Removed the dead fields (their doc comments falsely claimed "this frame"
semantics); the `Plot2D` toolbar methods remain the API. f64-vs-float32 note: silx casts to float32 before
`Histogramnd`; this port stays f64 (spec says `pixels: &[f64]`) — binning algebra is exact, a boundary
value can shift ≤ float32-epsilon vs silx.
- Gate (main, post-fix): fmt clean, clippy `-p siplot --all-targets` clean (medians compiled),
**700 tests pass** (+20: 11 median + 9 histogram), no doctest changed (new fences are ```ignore).
- **Deferred:** non-default silx median edge modes (reflect/mirror/shrink/constant — `MedianFilterAction`
only uses `nearest`); silx `HistogramWidget` RangeSlider for custom range, weighted (count·value)
histograms, and the embedded full `Plot1D`. **Wave 7 is now complete** (7A/7B/7C). Remaining backlog =
the shader-gated items (SOLID scatter-triangle pipeline, per-point scatter alpha, mask colormap GPU
overlay — NaN-holes) + marker drag/cursor + `is_overlay` data-layer (render-refactor) + true vector SVG.
### Wave 8 — Shader-gated GPU cluster (4 sequential sub-waves, all share the `high_level.rs` long-pole)
Verification boundary for this wave: WGSL is now **statically** validated headlessly via `naga` (8A) — the
prior "WGSL RUNTIME-UNVERIFIED (no naga CLI)" caveat is partly obsolete — but actual rendered pixels, GPU
alpha-blend, pipeline creation, and the per-vertex marker DRAW remain **GPU-only UNVERIFIED** on this machine
(Metal, no headless rasterizer). CPU geometry/data/LUT math is fully unit-tested. Each sub-wave = one
worktree-isolated implement + parallel adversarial verify; ff-merged to `main` preserving verified SHAs.
- **Wave 8A — headless naga WGSL validation gate** (`c253950`, 707 tests). New test-only `src/render/shaders.rs`
(registered `#[cfg(test)] mod shaders;` in `render/mod.rs`): `validate_wgsl(name, src)` =
`naga::front::wgsl::parse_str` + `Validator::new(ValidationFlags::all(), Capabilities::all())`, one `#[test]`
per shader (clear/curve/errorbars/fill/image/image_rgba/markers) via `include_str!`. **Zero new deps** —
`naga` is re-exported through `egui_wgpu::wgpu`. A malformed shader now fails `cargo nextest` headlessly.
- **Wave 8B — per-point marker color + per-point scatter alpha** (`62bc335` + `baf1715`, 712 tests). Fixes the
latent bug that the marker pipeline **dropped** per-vertex colors: `markers.wgsl` gains
`use_vertex_color: f32` (consumes a `_pad` slot — stays 112-byte std140, guarded by
`marker_params_std140_size_unchanged`) + `@binding(2) vcolors` storage buffer; `vs_main` passes
`vcolors[min(inst, len-1)]` through a color varying; `fs_main` = `select(params.color, in.color,
use_vertex_color > 0.5)` (regression-safe: flag 0 ⟹ old behavior). `gpu_curve.rs` adds the binding +
shares the existing per-vertex color buffer. `high_level.rs`: `compose_per_point_alpha` (round-trips
`to_srgba_unmultiplied` to avoid double-premultiply) + `ScatterView.alpha: Option<Vec<f64>>` +
`with_alpha`/`set_alpha`/`clear_alpha`; silx three-stage `colormap.alpha · per_point.alpha · global.alpha`
(`scatter.py __applyColormapToData rgbacolors[:,-1] *= __alpha`). **UNFIXED (out of scope, recorded):** the
pre-existing `apply_curve_alpha` double-premultiply bug on the shared curve/line path (kodex 162cc1a8).
- **Wave 8C — SOLID scatter triangle surface** (`6575cef`/`e5cc70d`/`5fc178b`, 717 tests; `high_level.rs` only).
`ScatterVisualization::Solid` rendered through the **existing CPU `epaint::Mesh` path** (no GPU shader
needed — recon corrected the assumption). Extracted `point_colors(values, colormap, alpha)` shared by the
Points + Solid arms (no drift); `ScatterView.triangles_handle` with the invariant *Some iff a mesh is
displayed*; Solid arm calls `scatter_viz::solid_triangles`, `None` on <3 finite / collinear clears the
handle and draws nothing (silx "Cannot display as solid surface" early-out). silx `scatter.py:610-625`
`backend.addTriangles` GL Gouraud. Enum-leak match arms fixed (`Points | Solid => None`; explicit Solid arm).
- **Wave 8D — mask 256×4 LUT overlay** (`12a7607` + `e38226a`, then 3 review fixes; 724 tests). Replaces the
flat single-color boolean overlay (which collapsed all 255 levels) with silx's faithful per-level LUT.
`core/colormap.rs`: `mask_overlay_lut(base, overrides, selected_level, alpha) -> [[u8;4];256]` faithful to
`_BaseMaskToolsWidget._setMaskColors` (base RGB → per-level override → `alpha/2` for all → full `alpha` at
the selected level → `lut[0]=[0,0,0,0]` set LAST; float→u8 = `(x*256).clamp(0,255) as u8` truncation
matching numpy `clip(c*256,0,255).astype(uint8)`). `mask_tools.rs::apply()` rewired to discrete
`lut[level] → RGBA` (**Option B**: reuses `image_rgba.wgsl`, NOT a scalar+colormap image — the
linear-filtering LUT sampler would blend adjacent levels and corrupt the selected-level alpha). New fields
`alpha` (default 0.8 = silx slider 8/10) + `overrides: Vec<Option<[u8;3]>>`(256); setters
`set_transparency`, `set_mask_colors(rgb, Option<u8>)`, `reset_mask_colors(Option<u8>)`; pure helpers
`mask_overlay_rgba` + `overlay_z_value`; z = active-image z + 1 (silx `MaskToolsWidget.py:482`, fallback
`_z=1`). `high_level.rs`: additive `add_rgba_mask`. Default overlay color corrected to silx
`rgba("gray")` = `#a0a0a4` (160,160,164) (gui/colors.py:71), not `#808080`. **Post-integration review fixes
(one commit per finding):** `71c52de` wrong silx gray (`#808080`→`#a0a0a4`); `33f1ed6` tautological z test →
test coupled to the real `overlay_z_value` helper; `8d671ef` `reset_mask_colors` per-level fidelity (silx
`resetMaskColors(level)`). UNVERIFIED (GPU): on-screen compositing, alpha blend, draw-on-top z-layering.
- **Wave 8 complete** (8A/8B/8C/8D), `main` @ `8d671ef`, 724 tests, full-workspace gate green. Remaining
backlog = marker drag/cursor (needs ownership decision) + `is_overlay` data-layer + `DirtyState::Overlay`
+ true vector SVG export + `delaunator` robustness upgrade (separately approvable).
### Wave 9 — Legend right-click context menu (silx `LegendSelector`)
Single cluster, all in `high_level.rs` (the single-writer long-pole), one worktree-isolated implement +
two parallel adversarial reviews (both **accept**), ff-merged preserving verified SHAs + one review-fix
commit. Recon confirmed **all 7 actions were already primitively backed** (no backend/render/shader change):
`CurveData` carries `symbol: Option<Symbol>` / `line_style: LineStyle` / `y_axis: YAxis`, and `GpuCurve::draw`
(gated on `line_style.draws_line()`) and `draw_markers` (gated on `symbol.is_none()`) are separate CPU-side
draw calls re-synced by the existing `update_curve_data` path. Verification boundary: PlotWidget cannot be
constructed without a GPU `RenderState` (no crate test builds one), so the egui menu render, the right-click
interaction, the rename `egui::Window`, and the on-screen toggle/axis-move EFFECT are **GPU/UI-UNVERIFIED**
on this machine; the testable core — the pure style transforms — is fully unit-tested.
- **`f10185c` — per-curve style setters + lossless restore cache** (730 tests; 6 new pure-fn tests). Public
`set_curve_y_axis(handle, YAxis)` (recomputes data bounds + auto limits like `remove`, since Left↔Right
reassigns which Y/Y2 bounds the curve feeds), `set_curve_points_visible`, `set_curve_lines_visible`.
Checkable Points/Lines are **lossless**: pure free fns `set_symbol_visibility` / `set_line_visibility`
stash the visible `Symbol`/`LineStyle` in a UI-only restore cache (`ItemRecord.hidden_symbol` /
`hidden_line_style`) so hide→show restores the exact variant (e.g. `Dashed`, not `Solid`), falling back to
documented defaults (`Symbol::Point` / `LineStyle::Solid`) only on an empty cache. Tests assert exact
variants + no-op-does-not-clobber + empty-cache-default boundaries. **No dual meaning:** the cache is never
read by any render/bounds/legend path — `CurveData.symbol`/`.line_style` stays the single source of truth.
- **`fd81309` — legend context menu + rename popup + response reporting**. `pub enum LegendAction`
(re-exported in `lib.rs` beside `LegendResponse`); `legend_row_response` now returns the row
`egui::Response` so `show_legend` attaches `Response::context_menu` while holding `&mut self`. Curve rows
(`PlotItemKind::Curve`) get the full silx `LegendListContextMenu` set — Set Active (disabled if already
active), Map to Y Left / Map to Y Right (current axis disabled), checkable Points (`symbol.is_some()`) and
Lines (`line_style.draws_line()`), separator, Rename, Remove — re-read from the record each frame so a
reopened menu is current. Non-curve rows (Image/Scatter/Histogram/Mask) get a graceful subset (Set Active /
Rename / Remove). Rename = silx `RenameCurveDialog` as an `egui::Window` (single-line `TextEdit` +
Apply/Cancel, Enter applies, Escape/close cancels) driven by `PlotWidget.rename_state`. Each action
self-applies and records `(handle, LegendAction)` in `LegendResponse.context_action`.
- **`71f5653` — review fix (one commit, structural):** the visibility setters wrote the restore cache back to
the record *before* `update_curve_data`; a failed update would have desynced the cache from the unchanged
drawn state. Now the cache is read by copy/clone and committed only on the update-success path (strong
state transition: the side cache commits through a finalizer that runs only after the real transition lands).
- **Wave 9 complete**, `main` @ `71f5653`, 730 tests, full-workspace gate green (`-p siplot` == sole
workspace member). The on-screen menu/rename/toggle render is GPU/UI-UNVERIFIED (see boundary above).
### Wave 10 — Active-curve highlight styling (silx `HighlightedMixIn` / `getCurrentStyle`)
Single cluster, all in `high_level.rs`; one worktree-isolated implement + two parallel adversarial reviews
(both **accept**), ff-merged preserving verified SHAs. **No review-fix commit was owed** — the two findings
were a verified harmless no-op and a GPU-blocked test gap (below). Recon confirmed no backend/render/shader
change: `CurveData`'s `color`/`width`/`line_style`/`symbol`/`marker_size`/`gap_color` are all normal per-curve
fields already pushed by the existing `backend.update_curve`, so the highlight is expressible purely as a
render-time style overlay. Verification boundary: PlotWidget needs a wgpu `RenderState` no crate test builds,
so the GPU push, the `set_active_item` revert/apply wiring, the public setters, and the on-screen thicker line
are **GPU/PlotWidget-UNVERIFIED**; the testable core (the pure merge) is unit-tested.
- **`d852b04` — highlight machinery + pure merge + state + setters + tests** (734 tests; 4 new pure-fn tests).
`pub struct CurveStyle` (override style: `Option` color/line_width/line_style/symbol/symbol_size/gap_color,
each `None` = inherit; re-exported in `lib.rs`). Pure free fn `current_curve_style(base, highlight,
highlighted) -> CurveData` = silx `getCurrentStyle`'s per-field merge exactly (when highlighted, each style
field = highlight's value if `Some` else base's; when not, base unchanged; data fields x/y/colors/fill/
baseline/errors/y_axis pass through). State: `active_curve_style` default `{ line_width: Some(2.0), ..None }`
(silx `DEFAULT_PLOT_ACTIVE_CURVE_LINEWIDTH=2` / `DEFAULT_PLOT_ACTIVE_CURVE_COLOR=None`) + `active_curve_handling`
default `true`; getters `active_curve_style()`/`is_active_curve_handling()`; single-owner helper
`sync_curve_highlight`; public setters `set_active_curve_style`/`set_active_curve_handling`. Tests assert
exact per-field values (not-highlighted→base verbatim; default→only width 2.0 with all else inheriting;
multi-field override with width inheriting). *(Commit-split note: the owner + public setters landed in this
commit, not commit 2, because commit 1 would otherwise carry a private `current_curve_style` with no
non-test caller → `clippy -D warnings` dead-code failure; `#[allow]` is banned. Net diff unchanged.)*
- **`d676c00` — wire the overlay into the automatic transitions**. **Invariant:** GPU render style of a curve
== `current_curve_style(retained_base, active_curve_style, is-active-curve)`; the retained `record.curve_data`
is ALWAYS the base (the owner pushes the resolved style to `backend.update_curve` only, never
`set_record_curve_data`) — single source of truth, no dual meaning (both reviewers traced
activate→update→deactivate: the base width is never corrupted). `set_active_item` reverts the previous curve
to base + applies the highlight to the new one through the owner; `update_curve_spec` re-overlays when the
updated handle is the active curve (so an update keeps the highlight), leaving non-active curves' base push
unchanged. Gated strictly on `PlotItemKind::Curve` (silx highlights only `kind=='curve'`; Scatter/Histogram,
though `is_curve_like`, are never highlighted).
- **Reviewer findings, no fix owed:** (1) MINOR — the setters resolve the target via `active_curve()`
(`is_curve_like`) which is broader than the strict `==Curve` highlight gate; both reviewers confirmed
harmless (the owner re-derives the strict gate and pushes an idempotent base for non-Curve handles, never a
dual-meaning record write). (2) GAP — the "retained base never corrupted" invariant is verified by code
tracing, not a headless test; reviewer 2 suggested it "could be tested without a GPU" but that is mistaken —
`record_curve_data`/`set_active_item`/`update_curve_spec` are all PlotWidget methods and `PlotWidget::new`
requires a `&RenderState` (live wgpu device) with no headless constructor, so the regression test is
GPU-blocked like the whole widget layer. Recorded honestly rather than faked with a GPU-requiring test.
- **Wave 10 complete**, `main` @ `d676c00`, 734 tests, full-workspace gate green. The on-screen highlight
render is GPU/PlotWidget-UNVERIFIED (see boundary above).
### Wave 11 — Marker drag + cursor (silx `DraggableMixIn.drag` → `setPosition`)
Cross-cutting cluster (6 source files + 3 examples); one worktree-isolated implement + two parallel adversarial
reviews (**both accept**, all six dimensions true), ff-merged preserving verified SHAs, then **one review-fix
commit** for a structural finding (below). **Key architecture:** the marker source of truth is
`WgpuBackend.items` (`BackendItem::Marker`); `plot.markers` is a per-frame, z-sorted **mirror** that
`sync_plot_items` rebuilds every frame, so a drag persists through a new inherent
`WgpuBackend::update_marker` (mirrors `update_image`), never by mutating the mirror alone. A parallel
`plot.marker_handles: Vec<ItemHandle>` (populated alongside `plot.markers` in `sync_plot_items`, same
length+order) bridges the handle-less mirror back to backend identity so `apply_interaction` (which holds only
`&mut Plot`) can report the dragged marker. Verification boundary: `PlotWidget`/`PlotView` need a wgpu
`RenderState` no crate test builds, so the grab/drag/precedence state machine, the cursor side-effect, the
`update_marker` round-trip, `set_marker_position`/`marker_position`, and the `PlotResponse`/event wiring are
**GPU/PlotWidget-UNVERIFIED**; the pure core is unit-tested.
- **`eb924e8` — persistence + handle plumbing + pure helpers + public API + tests**. `Plot::marker_handles`;
`WgpuBackend::update_marker`/`marker` (inherent, no trait change); pure `Marker::pick` per-kind hit-test in
`core/marker.rs` (backend `pick_marker` now delegates to it — dedup, not duplicate); pure
`interaction::marker_at` (topmost **draggable** marker under cursor, rev/z-order, skips non-draggable) and
`interaction::marker_cursor` (drag-DOF size cursor); `PlotEvent::MarkerMoved`; public
`set_marker_position`/`marker_position` (silx `setPosition`/`getPosition`, constraint applied via
`Marker::drag`). Unit tests: `Marker::pick` (in/out radius, on/off span for v/h-line), `marker_at`
(topmost / skip-non-draggable / none-on-miss), `marker_cursor` (all 5 kind+constraint mappings).
- **`8f6318a` — wire the drag into `apply_interaction`**. A draggable marker under the cursor at drag-start is
the **highest-precedence primary-drag consumer in every mode except MaskDraw**, pre-empting primary-drag pan,
the ROI-edge grab, and box-zoom (all gated on `!marker_dragging`); the marker size cursor takes precedence
over the ROI-edge cursor. Wheel zoom and secondary-drag pan are intentionally **not** gated (silx: only the
left-button item-drag branch competes with primary pan/zoom). `PlotWidget::show` persists the dragged marker
back to the backend item via `update_marker` and emits `PlotEvent::MarkerMoved`. *(Commit-split note:
`PlotEvent::MarkerMoved` + the public API landed in commit 1, not 2, because commit 1's `set_marker_position`
pushes the variant — an unknown variant would not compile; `#[allow]` is banned. The 3 examples gained a
`MarkerMoved` match arm as a forced `--all-targets` compile consequence of the exhaustive enum.)*
- **`994c591` — review-fix (structural): key marker drag by stable handle, not mirror index.** **Finding
(reviewer 2, non-blocking):** `MarkerDrag` stored a `usize` index into `plot.markers`, the per-frame z-sorted
mirror; reusing that positional value across frames as the grabbed marker's identity is a dual meaning, so a
mid-drag rebuild/reorder of the mirror could shift the index onto a different marker (bounds-checked, no
crash, but wrong-marker move). **Structural fix (preferred over the index patch):** store the marker's stable
`ItemHandle` in `MarkerDrag` and re-resolve the mirror index from it each frame (drag-apply + drag cursor); a
marker removed mid-drag now no-ops instead of moving an adjacent one. **Anchor audit:** `RoiDrag.roi` is the
same shape but **distinct** — it indexes `plot.rois`, the source of truth `sync_plot_items` never rebuilds or
reorders (not a derived mirror), and is pre-existing (out of Wave 11 scope) → left as-is (see UNFIXED).
- **UNFIXED (pre-existing, out of scope):** `RoiDrag` still keys an in-progress ROI drag by `usize` index into
`plot.rois`; robust today because that Vec is the truth and is not reordered per frame, but a handle/id-keyed
`RoiDrag` would be the same structural improvement. Not touched (Wave 11 scope is markers; needs user OK).
- **Wave 11 complete**, `main` @ `994c591`, 741 tests, full-workspace gate green. The on-screen drag, cursor,
`update_marker` round-trip, and event/response wiring are GPU/PlotWidget-UNVERIFIED (see boundary above).
### Wave 12 — On-plot ROI creation (all 11 shapes) + whole-ROI translate (silx `RegionOfInterestManager.start` → `setFirstShapePoints`)
One worktree-isolated implement + two parallel adversarial reviews (**both accept**, all six dimensions —
faithfulToSilx / additiveOnly / testMeaningful / precedenceCorrect / mappingComplete / unverifiedHonest — true),
ff-merged preserving the verified SHAs, then **one review-fix commit**. **Key architecture:** the `DrawState`
gesture machine (`DrawMode`/`DrawParams`/`on_press`/`on_move`/`on_release`/`preview`) and `show_with_draw`
already existed (Wave 4), so this wave is a thin *caller-side* `DrawParams`→`Roi` mapping (a pure, testable core
function) plus a `PlotInteractionMode::RoiCreate(RoiDrawKind)` mode that **reserves the primary drag** for the
draw gesture (the same shape as Wave 6C-2's MaskDraw). Unlike markers, `plot.rois` **is the source of truth**
(`sync_plot_items` never rebuilds or reorders it), so `RoiDrag` correctly keys by `usize` index into it — no
mirror/handle indirection is needed here. **Verification-boundary correction (vs the Wave-11 note):**
`apply_interaction` takes `&mut Plot` (a pure core type), so it *is* headlessly testable via
`egui::Context::default()` + `ctx.run_ui` — wgpu paint callbacks are recorded into the painter, never executed —
and the agent added 5 headless wiring tests (`run_mode_frame`) exploiting this. Only `PlotWidget::show` (needs a
wgpu `RenderState` no crate test builds) and the actual on-screen render stay GPU/PlotWidget-UNVERIFIED.
- **`7636cbd` — ROI-creation pure mapping (testable core).** `RoiDrawKind` (11 kinds, one per `Roi` variant);
`roi_draw_mode(kind)->DrawMode`; `roi_from_draw(kind, &DrawParams)->Option<Roi>` (the silx `setFirstShapePoints`
equivalent for all 11, incl. `arc_from_two_points`, a faithful port of silx `_circleEquation` /
`_createControlPointsFromFirstShape` circle-fit); `RoiGrab { Edge(RoiEdge), Translate }` +
`roi_grab_at(rois, transform, cursor, grab_px)->Option<(usize, RoiGrab)>` (handle-over-body, topmost-first);
new `DrawMode::Point` + `DrawParams::Point { x, y }` (single click → `Finished` immediately, for Point/Cross).
16 pure unit tests.
- **`c00ec50` — wire on-plot creation + whole-ROI translate into `apply_interaction`.** The `RoiCreate` block
drives `DrawState` from temp memory, appends the finished `Roi`, **re-arms continuously** (silx default), and
surfaces a live `roi_preview`; `RoiDrag` gains `RoiGrab::Translate` → `Roi::translate(delta)` alongside the
existing edge-move. `mode_grabs_roi_edge` excludes `RoiCreate` (so a draw gesture is never stolen by an edge
grab); new `mode_allows_marker_drag` keeps marker drag out of create mode. `Interaction.roi_created` /
`roi_preview` added; preview rendered via `draw_overlay`. `run_mode_frame` test helper + 5 headless
apply-interaction wiring tests.
- **`1512ed7` — public API + event + re-exports.** `PlotEvent::RoiCreated { index }` emitted in
`PlotWidget::show` (mirrors `RoiChanged`); `set_roi_create_mode(kind)`; `lib.rs` re-exports `RoiDrawKind`,
`RoiGrab`, `roi_draw_mode`, `roi_from_draw`, `roi_grab_at`, `arc_from_two_points`. *(Commit-split note:
`RoiCreated` forced a `--all-targets` match arm in the 3 examples — exhaustive-enum consequence, same shape as
Wave 11's `MarkerMoved`.)*
- **`7ff440d` — review-fix (R1.1): strengthen the Point ROI-create test.** The reviewer's literal suggestion
(assert `roi_created == Some(0)` on the *press* frame) could be wrong — a no-move click likely fires `on_press`
via `clicked()` on the *release* frame, so a frame-pinned assertion is fragile. Fixed instead with a
frame-agnostic "reported **exactly once**" assertion (collect `roi_created` across both press and release
frames, assert `== vec![0]`), which catches both a missing report and a double-create regardless of egui frame
collapse. Verified passing.
- **Documented deviations (silx-cited, not guessed):** `Roi::Ellipse` now models `EllipseROI._orientation` (an
`orientation` field; rotation geometry complete); X/Y bands use `Roi::VRange`/`HRange` (silx shows single-line `VerticalLineROI`/
`HorizontalLineROI` as X/Y markers — distinct single-line kinds are still ☐); Arc/Band/Circle default geometry
(widths, radii, angle conventions) read from silx source and cited in the mapping doc-comments.
- **UNFIXED (pre-existing, out of Wave 12 scope):** *(R2.1)* the `RoiDrag` apply block reads the in-progress drag
from temp memory **unconditionally of mode**, so a mid-drag mode switch can apply a stale grab one frame; this
is identical to `main`'s pre-Wave-12 behaviour (the same pattern the MarkerDrag/MaskDraw blocks use) and was not
introduced here. Also carried from Wave 11: `RoiDrag` keys by `usize` index — safe because `plot.rois` is the
truth, not a per-frame mirror, but a future id-keyed `RoiDrag` would be the uniform structural choice.
- **Wave 12 complete**, `main` @ `7ff440d`, 762 tests, full-workspace gate green (clippy `-D warnings` clean,
doctest ok). The on-screen creation, preview overlay, continuous re-arm, and whole-ROI translate are
GPU/PlotWidget-UNVERIFIED; the `DrawParams`→`Roi` mapping and the `apply_interaction` wiring are headlessly
unit-tested.
### Post-Wave-12 commits (landed on `main`, unlogged until the 2026-06-04 re-baseline)
These shipped after the Wave-12 progress entry as individual one-commit-per-feature fixes (not run as a
labelled wave), so they were not in the Progress log; the 2026-06-04 re-baseline folds them into the Done count.
- **ROI handle-drag editing** for the kinds Wave-12 creation left ◐: `e9b046b` Rect corner/diagonal resize,
`6b9e5d0` Band, `fc14807` Arc (incl. start/end-angle via `Vertex(2)`/`Vertex(3)`), `cac2edf` Circle/Ellipse
regression tests; `2dbbb8e` cancels an in-progress ROI drag on a mid-drag mode switch (**closes the Wave-12
R2.1 UNFIXED** — the stale-grab-one-frame window).
- **`cd0338d`** fixes the `apply_curve_alpha` double-premultiply on the shared curve/line path (**closes the
long-standing cross-wave UNFIXED**, kodex `162cc1a8`).
- **Detached tool windows:** `4c39632` detached-window helper sharing placement maths with `profile_window`;
`dea42fd` shows all floating tool windows as detachable native OS windows; `a8bb863` doc-example update.
- **Active-curve axis labels** (silx `_setActiveItem`): `2de1a8e` swap in the active curve's labels, `f2ce5b5`
example; **right-click zoom menu** `b389721` (replaces double-click reset).
- **Rotated-label centering fixes:** `b7b62bd` off-center Y / clipped y2 labels; `fbe7f6e` wrong-sign vertical
colorbar legend centering.
- **Curve legend icons** (silx `CurveLegendsWidget`/`LegendIcon`): `6fb9f33` line style + color + marker,
`c0f38dc` example, `525dfac` legend in `high_level_active_curve_labels`, `d04232a` drop the icon bounding box.
### Re-baseline (2026-06-04, recon workflow `wf_b2b09dc2-620`)
The "Remaining work (live, code-verified 2026-06-02)" view was stale (frozen pre-Wave-10, before the commits
above). An 8-slice read-only recon (Explore agents, one per silx.gui.plot slice → synthesis) re-derived every
status from the code on `main`, ignoring the old status column. Result: **≈191 Done · 130 open** (92 enumerated
rows: 10 H / 48 M / 34 L). The new view replaced the 2026-06-02 section at the top of this file; the candidate
next-wave list is recorded there. Two prior cross-wave UNFIXED items were verified **closed** by the post-Wave-12
commits (`apply_curve_alpha` double-premultiply; ROI mid-drag stale-grab). One precision correction over the
recon's raw output: the H-priority "ROI per-instance color/label/selection on canvas" is recorded as a
**two-decoupled-collections** structural gap — `chrome::draw_roi` + `RoiAppearance` already render color
(chrome.rs:732), name (`:916`), and selection-width (`:736`), and `RoiManagerWidget` feeds real appearance
(roi_manager.rs:223-235), but the live plot render (`plot_widget.rs:353 → draw_rois`) passes
`RoiAppearance::default()` over a bare `plot.rois: Vec<Roi>`; the fix is to plumb/join `ManagedRoi` metadata into
the live render path, not to add new render code.
### Wave 13 — ROI on-canvas styling + selection feedback (unify the two decoupled ROI collections)
Done **inline** (not the parallel worktree method), 4 one-feature-per-commit landings on `main` (not pushed),
each passing the full per-crate gate (`cargo fmt --all`; `cargo clippy -p siplot --all-targets -- -D warnings`
clean; `cargo nextest run -p siplot`; `cargo test --doc -p siplot`). Closes candidate-wave #1 — the top
H-priority cluster — via the **structural fix the re-baseline named**: the root cause was two decoupled ROI
collections (`Plot.rois: Vec<Roi>`, rendered live with a hardcoded `RoiAppearance::default()`, vs
`RoiManagerWidget`'s own `Vec<ManagedRoi>` whose `draw()` was never overlaid), so per-ROI styling never reached
the interactive plot.
- **`3c614c8` — Unify ROI collections: `plot.rois` holds `ManagedRoi` as the single source of truth.** Moved
`ManagedRoi`/`RoiLineStyle` from `widget::roi_manager` into `core::roi` (core may not depend on widget;
re-exported from `widget::roi_manager` + `lib.rs` for path compat). `Plot::rois` is now `Vec<ManagedRoi>`;
added `Plot::roi_color` (silx-default red) + private `current_roi`; the one live `chrome::draw_rois(painter, t,
rois: &[ManagedRoi], default_color, style)` path resolves each ROI's appearance. **Single-owner invariant:**
`Plot::set_current_roi` is the SOLE writer of every `ManagedRoi.selected`; `Plot::remove_roi` (remove + adjust
index) and `Plot::clear_rois` (clear + `current_roi=None`) are the SOLE collection-shrinking mutators → "exactly
the current ROI is highlighted; the current index never dangles" holds by construction. Bypass audit anchor
`rg '\.rois\.(push|remove|clear)'`: push sites distinct (a new ROI is not auto-current); the two high-level
remove/clear bypass sites routed through the owner. Adapted every `.roi` reader/writer (plot_widget render +
edit-drag + creation + cursor, interaction `roi_grab_at`, high_level `add_roi`/`rois`/`rois_mut`/
`show_roi_manager`, 4 examples). Tests: selection/removal invariant in `core::plot`.
- **`c60bae6` — high-level ROI styling API + current-ROI selection event.** `set_roi_color`/`set_roi_name`/
`set_roi_line_width`/`set_roi_line_style`/`set_roi_fill` (handle-indexed), `add_managed_roi`,
`current_roi`/`set_current_roi`; `PlotEvent::CurrentRoiChanged { previous, current }` emitted only when the
selection actually changes (silx `sigCurrentRoiChanged`). *(Commit-split note: the new variant forced a
`--all-targets` match arm in 3 exhaustive-match examples — same enum consequence as Wave 11/12.)*
- **`b29db3c` — rework `RoiManagerWidget` onto `plot.rois`; remove the second ROI collection.** The manager is now
stateless beyond its window (`{ win, open }`): every control (current-ROI radio, color swatch, name, line
width/style, fill, remove, the +shape and clear-all buttons) mutates the plot's single `ManagedRoi` collection
via the owner methods, so edits render on the plot at once. Its own `Vec<ManagedRoi>`/`current`/`default_color`
+ dead `draw()` removed (−4 owned-state tests). `examples/high_level_roi_manager.rs` now seeds two styled, named
ROIs (blue "feature A" rect; orange filled "spot" circle set current) in Select mode.
- **`7495590` — extract testable ROI appearance resolution.** Pure `chrome::roi_appearance(&ManagedRoi,
default_color) -> RoiAppearance` (color fallback, name→Option, selected, width, style, fill) with a headless
`mod tests` case — the GPU-render boundary's testable seam.
- **Verification boundary (reported honestly):** `PlotWidget::new` needs a wgpu `RenderState` no crate test
builds, so the on-screen ROI pixels stay **GPU/PlotWidget-UNVERIFIED**. Headlessly tested = the core
selection/removal invariant (`core::plot`) + pure `chrome::roi_appearance` resolution. Final gate: 781 tests
passed / 0 failed, doctest ok (11 ignored), clippy `-D warnings` clean.
- **Wave 13 complete**, `main` @ `7495590` (NOT pushed). Per-crate scope only (`-p siplot` == `--workspace`,
sole member), so the pre-push full-workspace pass is satisfied by the per-crate run.
### Post-release fit-theory completion (2026-06-16, after v0.3.0)
A FitWidget audit found siplot exposed only **8 of silx's 19 `FitManager`
theories** (`fittheories.py` `THEORY`) — a sub-feature gap inside the ✅ Fit
rows, the same class of miss as the Heart glyph. Ported the missing 11 as new
`PeakModel` + `FitModelChoice` variants (byte-faithful to `funs.c`; estimators
seed parameters only, matching the existing single-`PeakModel` estimators —
silx's multi-peak SameFwhm/constraint logic is not single-peak). **All 19 silx
`FitManager` theories are now implemented:**
- **`72de957`** — Area Lorentz (`sum_alorentz`), Split Gaussian
(`sum_splitgauss`), Split Lorentz (`sum_splitlorentz`).
- **`9e90926`** — Area Pseudo-Voigt (`sum_apvoigt`), Split Pseudo-Voigt
(`sum_splitpvoigt`), Split Pseudo-Voigt 2 (`sum_splitpvoigt2`).
- **`a476018`** — Degree 2–5 Polynomial (`fitfuns.poly`/`estimate_poly`), reusing
`core::background`'s numpy-convention `polyfit`/`poly_eval`; corrects the
row-557 note that wrongly said silx has no poly *fit* theory.
- **`22ef3df`** — Hypermet (`sum_ahypermet`): the 19th theory. Gaussian +
short-tail + long-tail + step (`tail_flags = 15`, silx's default
`HypermetTails`), byte-faithful including the `fabs(slope) > 1e-11` tail guard.
17 new headless tests (asymmetric/area recovery, split→symmetric collapse,
area↔height identity, poly coefficient recovery, Hypermet tails-zero reduction
to the area Gaussian + own-curve recovery + Initial-ratio seeds). Gate per
commit `-p siplot`; `--workspace` clippy clean + `nextest` 1861/1861.
**Hypermet design decision (the one open question, resolved in `22ef3df`).** A
*faithful* Hypermet fit interacts with silx's default per-tail constraints:
silx's `estimate_ahypermet` zeroes+`CFIXED`s a tail below its
`MinGaussArea4{Short,Long}Tail`/`MinGaussHeight4StepTail` counts threshold and
otherwise `CQUOTED`s the ratios to their `[Min, Max]` bounds (to "avoid zero
derivatives", `fittheories.py:870`) — its tail activation is inseparable from
that default constraint set. siplot's `PeakModel` estimate→fit path is
unconstrained by default (the constraint machinery — `Constraint`,
`fit_peak_constrained`, the per-parameter UI — is fully present but
FitWidget-set, defaulting all-`Free`), an established project-wide invariant
shared by all 18 other theories. Rather than bolt a one-off constraint-defaulting
mechanism onto the pipeline for a single model (rejected by the
senior-reviewer self-test as speculative for the other 18), Hypermet follows the
same invariant: `estimate_ahypermet` seeds the five tail ratios to silx's
`Initial*` values *unconditionally* (`0.05/0.70/0.05/20.0/0.002`), which is
non-degenerate at the seed (every Jacobian column non-zero); applying
`CFIXED`/`CQUOTED` through the constraint UI reproduces silx's exact tail
handling. The unconditional-seed deviation is documented on the estimator.
## PlotWidget core, axes, frame, ticks — 9✅ 0◐ 0☐
siplot has strong coverage of core axis features: linear/log/inverted axes, dual Y2 axis, axis labels with active-curve-label fallback (`resolved_axis_label`), grid modes (major/major+minor/none) with an independent grid color, nice-number tick layout with minor ticks, TIME_SERIES/datetime axes (DST-aware named IANA zones via `TimeZone`), per-axis autoscale (`set_x/y/y2_autoscale`, honored by `reset_zoom_to_data_range`), data-margin expansion ratios for reset-zoom (`DataMargins`, log-aware), axis-visibility toggle (`set_axes_displayed`), axis constraints, keep-aspect-ratio, and auto-zoom-to-data. The silx dirty/replot lifecycle is resolved-not-a-gap (siplot is immediate-mode with persistent GPU buffers; `DirtyState` is a faithful port, set not consumed). `Plot::data_range()` mirrors silx `getDataRange` and is kept live by `recompute_data_bounds` (every content change pushes the raw per-axis bounds into the cache), so this section is fully covered.
| | P | E | Feature | silx | gap |
|---|---|---|---|---|---|
| ✅ | H | M | Per-axis autoscale control (Axis.setAutoScale / isAutoScale) | `PlotWidget.py:2935-2957, items/axis.py:310-324` | `core/plot.rs` per-axis flags `x_autoscale`/`y_autoscale`/`y2_autoscale` (silx `Axis.setAutoScale`/`isAutoScale`) with matching getters/setters (`:596-625`); `reset_zoom_to_data_range` (`:842-875`) refits only axes whose flag is on, and forces autoscale on a log axis whose lower limit is ≤ 0 (silx). Tested `:1149`/`:1165`. |
| ✅ | H | S | Data range computation (getDataRange for bounds auto-expand) | `PlotWidget.py:908-918, resetZoom:3331-3350` | `core/plot.rs` `DataRange` (silx `_PlotDataRange`, each member `(min,max)` or `None`) + `data_range()`/`set_data_range()` cache (silx `getDataRange`); `reset_zoom_to_data_range` consumes a `DataRange`. The cache is kept live in `recompute_data_bounds` — the single content-change funnel (silx `_notifyContentChanged`→`_updateDataRange`): every add/update/remove pushes the raw per-axis min/max via `set_data_range(raw_data_range_from_bounds(..))`, so `Plot::data_range()` reflects the data on all paths instead of reading all-`None`. A single point reads as `(v, v)` un-padded, matching silx getDataRange; the non-degenerate span + data margins stay a refit-time concern in `data_range_from_bounds`. Tested: `raw_data_range_from_bounds_keeps_raw_bounds_unpadded`, `recompute_data_bounds_populates_live_data_range_cache`. |
| ✅ | M | L | TIME_SERIES / datetime axis tick mode (TickMode enum) | `items/axis.py:43-48, 296-308, _utils/dtime_ticklayout.py (entire file)` | `core/dtime_ticks.rs` ports the layout kernel: `DtUnit` (silx `DtUnit`), `best_unit` (`bestUnit`), `calc_ticks_tz`/`format_ticks_tz` (`calcTicks`/`formatDatetimes`), `DateTime` + `TimeZone` (Utc/FixedOffset). `core/plot.rs` `enum TickMode { Numeric, TimeSeries }` + `set_x_tick_mode`/`x_tick_mode` (silx `getXAxis().setTickMode`, X-only) + `set_time_zone`/`time_zone` (`setTimeZone`). Wired live: `plot_widget.rs:338 draw_axes_with_x_tick_mode` → `chrome.rs:283` formats datetime labels when `TimeSeries` on a linear axis (ignored on log, per silx). Tested (`chrome.rs:1552`, `plot.rs:1413`). |
| ✅ | L | M | Data margin ratios for resetZoom (setDataMargins / getDataMargins) | `PlotWidget.py:3251-3270, resetZoom:3352-3397 margin expansion logic` | `core/plot.rs` `DataMargins { x_min,x_max,y_min,y_max }` per-side ratios (silx `setDataMargins`) + `data_margins`/`set_data_margins` (`:653-661`); `DataMargins::expand_axis` expands in log space and skips non-positive log bounds (silx `addMarginsToLimits`); `reset_zoom_to_data_range` applies them only to refit axes (`:854-875`). Tested `:1452-1524` (linear/log/log-skip/refit-only). |
| ✅ N/A | L | M | Dirty flag and replot lifecycle (_setDirtyPlot, replot, autoreplot) | `PlotWidget.py:719-730, 3309-3311, 3279-3289` | Resolved W15 — not a gap. silx's `_dirty`/`replot`/`autoreplot` are a retained-backend deferral mechanism; siplot is immediate-mode (egui owns the frame loop) with persistent GPU buffers, so redraw is always-on and uploads are event-driven. `DirtyState` exists as a faithful port (set, not consumed). Not a correctness issue. |
| ✅ | L | M | Axis label fallback to active curve label | `items/axis.py:187-218 (setLabel, _setCurrentLabel; PlotWidget handles active curve label swapping)` | `core/plot.rs` `resolved_axis_label(default, active)` (`:190`) + `x_axis_label`/`y_axis_label`/`y2_axis_label` (`:764-777`): the active curve's per-axis label wins over the default, falling back when empty. Wired live — `high_level.rs` `active_axis_label_overrides`/`sync_active_axis_labels` (`:2170`/`:3174`) route the active curve's labels onto `Plot::active_*_label`, and `plot_widget.rs:405` renders `displayed_x_label()`. Tested `:1263-1293`. |
| ✅ | L | S | Axis visibility toggle (setAxesDisplayed / isAxesDisplayed) | `PlotWidget.py:2838-2855` | `core/plot.rs` `axes_displayed` flag + `set_axes_displayed` (marks the plot dirty; silx `setAxesDisplayed`/`isAxesDisplayed`, `:666-676`). Wired: `plot_widget.rs:277` reads it, `:286` sets `axes_hidden`, and `:382`/`:404` skip frame/ticks/labels when hidden (chrome `:111`). Tested `:1336`/`:1385`. |
| ✅ N/A | L | S | Overlay-only replot optimization | `PlotWidget.py:719-730 (dirty='overlay')` | Resolved W15 — not a gap. siplot is immediate-mode with persistent GPU buffers (data uploaded on add/update, not per frame); there is no per-frame full upload for dirty='overlay' to skip. Parity achieved differently. |
| ✅ | L | S | Separate foreground (axes/frame) and grid colors | `PlotWidget.py:setForegroundColor, setGridColor (separate calls); backends/_PlotFrameCore.py splits axis vs grid stroke` | `core/plot.rs` `foreground` and `grid_color` are independent `Option<Color32>` (silx `setForegroundColor`/`setGridColor`, `:381-384`/`:805-816`); chrome `Theme::with_overrides(fg, grid)` (`chrome.rs:52`) takes both and defaults the grid to a faint tint of the foreground when unset; `plot_widget.rs:331` passes `plot.grid_color` through, so axis/frame stroke and grid lines render with independent colors. |
## Interaction, events, panzoom, limits history — 29✅ 0◐ 0☐
siplot implements the full pan/zoom interaction surface: pan and wheel zoom (both log-axis-aware — performed in log10 space with float32-safe clamps), box zoom, interaction-mode switching (select/pan/zoom), double-click reset, crosshair cursor, arrow-key panning, and an unbounded limits-history (zoom-back) stack. All silx draw modes are present — Rectangle/Ellipse/Line/HLine/VLine/Polygon/FreeHand/Point via `DrawMode`/`DrawParams` — with live previews. The signal surface is modeled as the `PlotEvent` enum (plus `PlotPointerEvent` for raw pointer events): mouseClicked/mouseDoubleClicked, hover (`ItemHovered` with kind/label/data+pixel position/draggable), drawingProgress/drawingFinished, limitsChanged (x/y/y2 tuples), curveClicked/imageClicked/itemClicked (markerClicked folds into the generic item-pick), and the full marker drag lifecycle. silx's `selectable` hover flag is the one documented omission (constant-true in siplot's active-on-click model). The silx interaction state machine (`StateMachine`/`ClickOrDrag`) is achieved differently via imperative `apply_interaction` dispatch + `DrawState` — resolved-not-a-gap.
| | P | E | Feature | silx | gap |
|---|---|---|---|---|---|
| ✅ | H | M | Pan respects logarithmic axes with proper math | `PlotInteraction.py:233-290` | `interaction.rs` `pan_axis` (`:42`) shifts a `Scale::Log10` axis in log10 space (`d_log = delta_px·(log_max−log_min)/extent_px`, silx `dx = log10(xData) − log10(lastX)`) and linearly otherwise; `pan` (`:79`) selects per-axis `x_scale`/`y_scale`. Non-positive log bounds fall back to a no-op. |
| ✅ | H | M | Wheel zoom respects logarithmic axes | `panzoom.py:80-119` | `interaction.rs` `scale1d_range` (`:100`) zooms a log axis in log10 space and clips to the positive float32 range (`FLOAT32_MINPOS`..`FLOAT32_SAFE_MAX`; silx substitutes `FLOAT32_MINPOS` for a non-positive bound); `zoom_about` (`:148`) passes `x_scale == Scale::Log10` per axis (silx `scale1DRange`). |
| ✅ | M | L | Limits history (undo/redo stack for zoom history) | `LimitsHistory.py:34-82` | `core/plot.rs` `limits_history: Vec<LimitsHistoryEntry>` (left + optional y2 ranges) pushed before each zoom/box-zoom/pan; `zoom_back` pops the most recent (silx `LimitsHistory`, unbounded stack). |
| ✅ | M | L | Draw mode (polygon, rectangle, ellipse, line, freehand, etc.) | `PlotInteraction.py:1648-1683` | `interaction.rs` `enum DrawMode` (Rectangle/Ellipse/Line/HLine/VLine/Polygon/FreeHand/Point) + `enum DrawEvent { InProgress, Finished }` (silx drawingProgress/drawingFinished) via `DrawState`; wired through `show_with_draw`/`feed_draw_state`, surfaced as `PlotEvent::DrawingFinished`. |
| ✅ | M | L | Polygon drawing interaction (select mode) | `PlotInteraction.py:485-621` | `interaction.rs` polygon mode: point-by-point vertices, snaps the last vertex to the first within `close_threshold_px` (silx onMove); close target rendered by `draw_polygon_first_point` (silx `updateFirstPoint`). |
| ✅ | M | M | Pan via arrow keys (left/right/up/down directional input) | `PlotInteraction.py (search PlotWidget for arrow handling)` | `interaction.rs` `apply_pan` + `enum PanDirection` (silx `applyPan`) for arrow-key panning. |
| ✅ | M | M | Zoom enforces float32 safety limits during zoom | `PlotInteraction.py:241-250, panzoom.py:44-47` | `interaction.rs` `FLOAT32_SAFE_MIN/MAX` (= silx ±1e37) + `FLOAT32_MINPOS`; zoom clamps linear and log limits into the safe range (`:53-135`) so span subtractions cannot overflow f32. |
| ✅ | M | M | Signal: mouseClicked with button, position (data and pixel coords) | `PlotInteraction.py:168-199, PlotEvents.py:58-70` | `interaction.rs` `PlotPointerEvent::Clicked { button: MouseButton, data:(f64,f64), pixel:(f32,f32) }` (+ DoubleClicked/Moved); emitted by `detect_pointer_event`, surfaced as `PlotResponse.pointer_event`. |
| ✅ | M | M | Selection area visualization (semi-transparent overlay during zoom/draw) | `PlotInteraction.py:98-141 (setSelectionArea), 421-430 (draw area), 526-528, etc.` | `interaction.rs` `enum FillMode { Hatch, Solid, None }` + `SelectionStyle` + `hatch_lines`; `draw_selection_polygon` (solid = color@half-alpha, hatch via clipped diagonals, dashed outline) is shared by box-zoom AND every draw-mode preview (`draw_overlay`/`paint_draw_preview`). |
| ✅ | M | M | Rectangle drawing interaction (select mode) | `PlotInteraction.py:767-807` | `interaction.rs` Rectangle two-point phase → `DrawParams::Rectangle { x, y, width, height }` (normalized corners) with a 4-corner preview ring. |
| ✅ | M | M | Floating-point overflow protection during pan/zoom | `PlotInteraction.py:233-289, panzoom.py:114-118` | Same `FLOAT32_SAFE_MIN/MAX` clamps apply on the pan/zoom paths in `interaction.rs` (`:53-135`), clipping results into the f32-safe range rather than only checking `is_valid()`. |
| ✅ | L | L | Signal: drawingProgress (real-time feedback during shape drawing) | `PlotInteraction.py:529-532, 789-792, 903-906, etc., PlotEvents.py:34-55` | `PlotEvent::DrawingProgress { mode, points }` (silx `drawingProgress`) carries the current rubber-band's data-space vertices; emitted each frame the preview advances (`high_level.rs:3128`) from `DrawEvent::InProgress`. |
| ✅ | L | L | Signal: drawingFinished (on shape completion) | `PlotInteraction.py:545-548, 800-803, 911-914, etc., PlotEvents.py:34-55` | `PlotEvent::DrawingFinished { mode, params: DrawParams }` (silx `drawingFinished`) carries the resolved shape parameters; emitted on draw completion (`high_level.rs:3132`) from `DrawEvent::Finished`. |
| ✅ N/A | L | L | Interaction state machine (ClickOrDrag, StateMachine) | `Interaction.py:87-198, PlotInteraction.py:153-209` | Resolved — not a gap. Implementation structure, not a user feature; siplot achieves the same interaction behavior with imperative `apply_interaction` dispatch + `DrawState`. Functional parity achieved differently. |
| ✅ | L | M | Signal: hover (mouseMoved) with item label, type, draggable/selectable flags | `PlotInteraction.py:1135-1154, PlotEvents.py:73-85` | `PlotEvent::ItemHovered { handle, kind, label, x, y, xpixel, ypixel, draggable }` (silx `hover`/`prepareHoverSignal`) emitted on bare-hover over a pickable item (`high_level.rs:3381`). silx's `selectable` flag is intentionally omitted — every pickable item activates on click, so it would be a constant `true` carrying no information. |
| ✅ | L | M | Signal: markerClicked with marker details and position | `PlotInteraction.py:1223-1241, PlotEvents.py:88-139` | A marker click is surfaced via the generic `PlotEvent::ItemClicked { handle, button }` (`high_level.rs:3343`, doc'd as covering "marker, scatter, or shape" — silx `markerClicked` + the generic item-pick path); read the kind via `item_kind` and the position via `marker_position(handle)`. |
| ✅ (W14) | L | M | Signal: markerMoving/markerMoved (marker drag feedback) | `PlotInteraction.py:1276-1299, 1350` | Now split: `MarkerMoved{handle}` each frame during the drag (silx `markerMoving`) plus a distinct on-release `MarkerDragFinished{handle}` (silx `markerMoved`), bracketed by `MarkerDragStarted{handle}`. Each event carries the handle; position is read via `PlotWidget::marker_position`. |
| ✅ (W14) | L | M | Signal: curveClicked with curve indices and position | `PlotInteraction.py:1243-1261, PlotEvents.py:159-173` | `PlotEvent::CurveClicked{handle,index,x,y,button}` carries the nearest picked vertex index + data position + button. (silx's full xdata/ydata arrays are reachable via the handle, not inlined.) |
| ✅ (W14) | L | M | Signal: imageClicked with pixel (col, row) index and position | `PlotInteraction.py:1263-1272, PlotEvents.py:142-156` | `PlotEvent::ImageClicked{handle,col,row,button}` emitted from the `pick_topmost` owner path over the existing `image_index`/`pick_image_pixel` picker. |
| ✅ | L | M | Cursor shape change (resize cursors for draggable markers/handles) | `PlotInteraction.py:1165-1184` | ROI-edge resize cursors were already set; Wave 11 adds the draggable-marker size cursor (marker_cursor: VLine→SizeHor, HLine→SizeVer, free Point→SizeAll, constrained Point→the free axis), shown on hover and during drag, taking precedence over the ROI-edge cursor. Matches silx CURSOR_SIZE_HOR/VER/ALL. |
| ✅ | L | M | Selection area color and fill mode (hatch, solid, none) | `PlotInteraction.py:98-141` | Closed W15: box-zoom + draw preview share one selection-overlay renderer honoring FillMode Hatch/Solid/None; box-zoom uses silx Zoom `fill="none"` dashed outline. |
| ✅ | L | M | Ellipse drawing interaction (select mode) | `PlotInteraction.py:681-765` | `interaction.rs` `DrawMode::Ellipse` → two-point drag resolved to `DrawParams::Ellipse { center, semi-axes }` (silx `SelectEllipse`, `:369`); handled in `feed_draw_state` (`:503`/`:550`) with a live preview, surfaced via `DrawingFinished`. |
| ✅ | L | M | Freehand/polyline drawing (select mode) | `PlotInteraction.py:955-1110` | Done: `DrawMode::FreeHand` accumulates vertices (DrawState); the mask pencil footprint preview circle is now drawn at the cursor (W15, `ImageView::draw_brush_preview` + `pencil_preview_circle`). |
| ✅ | L | M | Axis constraints (minXRange, maxXRange, minYRange, maxYRange) | `panzoom.py:222-366` | Closed W15: `AxisConstraints::apply` mirrors silx `ViewConstraints` allow_scaling=True normalization — max-range capped to the position window, and a view wider than the window snaps to it (adaptive expansion) |
| ✅ | L | S | Signal: mouseDoubleClicked with position | `PlotInteraction.py:168-177, PlotEvents.py:58-70` | `PlotPointerEvent::DoubleClicked { button, data, pixel }` (silx `prepareMouseSignal("mouseDoubleClicked")`, `interaction.rs:1617`/`:1653`); emitted from `detect_pointer_event` (`plot_widget.rs:1289`) and surfaced as `PlotResponse.pointer_event`. Tested `:2466`. |
| ✅ | L | S | Signal: limitsChanged with x/y/y2 range tuples | `PlotEvents.py:176-184` | `PlotEvent::LimitsChanged { x: (f64,f64), y: (f64,f64), y2: Option<(f64,f64)> }` (silx `limitsChanged`) carries the new left x/y ranges and the optional right (y2) range; emitted on pan/zoom/programmatic update (`high_level.rs:3277`). |
| ✅ | L | S | Line drawing interaction (select mode) | `PlotInteraction.py:809-840` | `interaction.rs` `DrawMode::Line` → `DrawParams::Line { start, end }` (silx `SelectLine`, `:375`); two-point drag handled in `feed_draw_state` (`:503`/`:550`) with a preview, surfaced via `DrawingFinished`. |
| ✅ | L | S | Horizontal line drawing (select mode) | `PlotInteraction.py:885-918` | `interaction.rs` `DrawMode::HLine` → `DrawParams::HLine { y }` (silx `SelectHLine`, `:378`); one-point capture handled in `feed_draw_state` (`:507`/`:554`), surfaced via `DrawingFinished`. |
| ✅ | L | S | Vertical line drawing (select mode) | `PlotInteraction.py:920-953` | `interaction.rs` `DrawMode::VLine` → `DrawParams::VLine { x }` (silx `SelectVLine`, `:380`); one-point capture handled in `feed_draw_state` (`:507`/`:554`), surfaced via `DrawingFinished`. |
## Items: Curve, Histogram, Scatter — 14✅ 0◐ 0☐
siplot covers curve rendering (solid/dashed lines, single symbols, error bars, fill + baseline) and per-vertex colors, with correct line-style scaling and custom dash patterns. All 18 silx point symbols are implemented, including the `'♥'` Heart glyph (cardioid SDF in `markers.wgsl`, code 18). Histograms render as filled step curves with left/center/right alignment tracking (`HistogramAlign`/`histogram_edges`) and filled-region picking (`pick_histogram`: bbox + searchsorted bin + baseline≤y≤value). Scatter supports all silx visualization modes — Points, Solid (Delaunay), RegularGrid, IrregularGrid, and BinnedStatistic (2D binning with mean/count/sum over a rows×cols grid) — plus per-point alpha, and mode-specific picking. Active-curve highlight styling is applied at render (Wave 10) via silx's `getCurrentStyle` per-field merge. IRREGULAR_GRID now renders as silx's vertex-indexed quadrilateral triangle mesh (`irregular_grid_triangles`), so its picking maps the cell under the cursor to its source point (`irregular_grid_pick`, silx vertex `// 4`) — the render redesign that closed rows 152/1086. No remaining ◐ in this section.
| | P | E | Feature | silx | gap |
|---|---|---|---|---|---|
| ✅ | H | M | Curve symbols (circle, square, cross, plus, triangle) | `core.py:722-820 (SymbolMixIn), supported list includes o,d,s,+,x,.,etc.` | `core/items.rs` `Symbol` now has all 18 silx symbols (Circle/Diamond/Square/Plus/Cross/Point/Pixel/VerticalLine/HorizontalLine + 4 Tick + 4 Caret + Heart) plus the egui-extra Triangle; `Symbol::ALL`/`name()`/`from_code` map them to silx codes; rendered in `markers.wgsl` (`code()` 0-18, the `'♥'` Heart glyph being silx's cardioid SDF at code 18). |
| ✅ | M | L | Scatter visualization mode: Solid (Delaunay triangulation) | `scatter.py:283-296, core.py:1271-1275 (Visualization.SOLID)` | `core/scatter_viz.rs` `delaunay` (Bowyer-Watson) + `solid_triangles`; `ScatterView::rebuild_visualization` Solid arm → `add_triangles_data` → `chrome::draw_triangles` (egui Mesh, per-vertex Gouraud colors). Render path is egui-tessellated, not a wgsl shader; GPU-unverified on-screen. |
| ✅ | M | L | Scatter visualization mode: IrregularGrid (vertex-indexed triangle mesh) | `scatter.py:283-296, 682-797, core.py:1286-1293 (Visualization.IRREGULAR_GRID)` | `scatter_viz.rs` `irregular_grid_triangles` arranges the points onto the detected grid (`arrange_irregular_grid_points`), builds the dual cell-corner grid (`quadrilateral_grid_coords`, interior corners = mean of 4 neighbours) and emits one flat-shaded quad (2 triangles) per point (silx `_quadrilateral_grid_as_triangles`) → IrregularGrid arm of `rebuild_visualization` → `add_triangles_data` → `chrome::draw_triangles`. Replaces the prior interpolated-image render (the `irregular_grid_image` barycentric raster remains as a standalone core primitive). Headlessly tested; egui-tessellated, GPU-unverified on-screen. |
| ✅ | M | M | Curve highlight/selection state with different style | `curve.py:196,280-311 (getCurrentStyle), core.py:1875-1905 (HighlightedMixIn)` | Wave 10: the active curve renders with a distinct highlight style. `current_curve_style` is silx `getCurrentStyle`'s per-field merge (highlight field if Some, else the curve's own); default active style = line width 2 (silx `DEFAULT_PLOT_ACTIVE_CURVE_LINEWIDTH`), color unchanged. Applied as a render-time overlay (retained base stays the single source of truth), gated to `PlotItemKind::Curve`. **GPU-UNVERIFIED:** the on-screen thicker line needs a GPU `RenderState` no crate test constructs; only the pure merge is unit-tested. |
| ✅ | M | M | Histogram bin alignment (left, center, right) | `histogram.py:53-85 (_computeEdges), setData(align='center'/'left'/'right'), getAlignment` | W15: `HistogramAlign` + pure `histogram_edges(positions, align)` mirror silx `_computeEdges`; public `add_histogram_aligned`/`_with_legend` accept N positions + N counts + alignment. Headlessly tested per alignment. |
| ✅ | M | M | Scatter visualization mode: RegularGrid (image-like grid rendering) | `scatter.py:283-296, core.py:1277-1282 (Visualization.REGULAR_GRID)` | `scatter_viz.rs` `detect_regular_grid`/`guess_z_grid_shape` auto-detect the grid shape; `regular_grid_image` renders via the image pipeline (grid arm of `rebuild_visualization`). Tested. GPU-unverified on-screen. |
| ✅ | M | M | Scatter per-point alpha transparency | `scatter.py:1009,1024,1051-1060 (setData with alpha parameter, __alpha field)` | `ScatterView.alpha: Option<Vec<f64>>` + `with_alpha`/`set_alpha`/`clear_alpha`; `compose_per_point_alpha` applies `colormap.alpha × per_point × global` (clamped) in the Points and Solid arms. Grid modes intentionally ignore per-point alpha (matches silx). |
| ✅ | M | M | Scatter picking with mode-specific logic | `scatter.py:804-860 (pick with special handling per Visualization mode)` | `ScatterView::show_position_info` dispatches the live pick by visualization mode (silx `Scatter.pick` + `_pickScatterData`): REGULAR_GRID → `regular_grid_pick` (cursor cell → index by major order, no radius), BINNED_STATISTIC → `BinnedStatistic::pick` then `nearest_candidate_in_data` (nearest bin point, highest-index tie-break, silx `indices[::-1]`+argmin), POINTS/SOLID → nearest-pixel `scatter_pick_pixels`, IRREGULAR_GRID → `irregular_grid_pick` (point-in-cell over the retained quadrilateral mesh, triangle `t` → point `t/2`, silx picked-vertex `// 4`). The IRREGULAR_GRID gap is closed by the triangle-mesh render redesign (row 1081): each point now owns a vertex-indexed cell, so the silx `// 4` mapping has a direct equivalent. Closes row 152. |
| ✅ | M | M | Plot item selection/highlight state tracking | `core.py:1875-1905 (HighlightedMixIn with setHighlighted/isHighlighted)` | Wave 10: the active-item selection is now propagated to the renderer for curves. `set_active_item` reverts the previous active curve to its base style and applies the highlight overlay to the new one (silx `setHighlighted(False)`/`(True)` in `_setActiveItem`), through a single owner `sync_curve_highlight`; `set_active_curve_style`/`set_active_curve_handling` mirror silx. Image/scatter selection still has no highlight style (silx highlights only `kind=='curve'`). |
| ✅ | L | L | Histogram picking (filled region sensitivity) | `histogram.py:244-290 (pick, __pickFilledHistogram with bounds check)` | `pick_histogram(edges, values, baseline, x, y)` ports silx `__pickFilledHistogram`: data-space bounding box (`_getBounds`, y includes 0), `searchsorted(side="left")-1` bin index via `partition_point`, then the `baseline ≤ y ≤ value` fill check. Tested (locate-bin/outside-bbox/nonzero-baseline). |
| ✅ | L | L | Scatter visualization mode: BinnedStatistic (2D histogram with statistics) | `scatter.py:283-296, core.py:1295-1301 (Visualization.BINNED_STATISTIC)` | `binned_statistic(x, y, values, rows, cols)` (scatter_viz.rs) bins points into a rows×cols grid with per-bin mean/count/sum (`BinnedStatistic`); rendered through the colormapped-image pipeline and pickable via `BinnedStatistic::pick`. Tested. |
| ✅ | L | S | Scatter visualization parameter: grid_major_order (row/column) | `core.py:1303-1308, 1346 (VisualizationParameter.GRID_MAJOR_ORDER)` | `GridMajorOrder::{Row, Column}` (scatter_viz.rs) drives `regular_grid_pick` index math (`row*cols+col` vs `row+col*rows`) and `RegularGrid.order`; auto-detected by `detect_regular_grid`. Tested. |
| ✅ | L | S | Scatter visualization parameter: binned_statistic_function (mean/count/sum) | `core.py:1325-1329, 1347 (VisualizationParameter.BINNED_STATISTIC_FUNCTION)` | `BinnedStatisticFunction::{Mean, Count, Sum}` selects the per-bin reduction via `BinnedStatistic::select`. Tested. |
| ✅ | L | S | Scatter visualization parameter: binned_statistic_shape (grid dimensions) | `core.py:1325,1333 (VisualizationParameter.BINNED_STATISTIC_SHAPE)` | The grid dimensions are the `rows`/`cols` arguments to `binned_statistic` (returns `None` when either is 0); `BinnedStatistic` stores the rows×cols arrays. Tested. |
## Items: Image, Marker, Shape, Complex — 12✅ 0◐ 0☐
siplot implements core image (scalar colormapped + RGBA direct), marker (point/vline/hline), and shape (polygon/rectangle/polyline/hline/vline) drawing. Colormaps support 8 built-in names with linear/log/sqrt/gamma normalization and a 256-entry LUT. Images support origin/scale placement, GPU tiling for large datasets, per-pixel validity masking (NaN→transparent), nearest/linear data interpolation, max/mean/min aggregation downsampling, and per-pixel alpha maps. Markers support point symbols, draggability with horizontal/vertical/custom-callback constraints, text labels with 9-way anchor positioning, and line styling. Shapes support fill, dashed-gap outline styling, and a data-vs-overlay layer flag. Complex images render via `ComplexImageView` (all 7 silx modes), 3D stacks via `ImageStack` (in-memory frame browser), and infinite lines via the `Line` item (`y = slope·x + intercept`). Per-pixel image alpha (GPU alpha texture + shader sample, retained across re-uploads) and concave-polygon fill (triangulated mesh fill) are now both closed — this section has no remaining gaps.
| | P | E | Feature | silx | gap |
|---|---|---|---|---|---|
| ✅ | M | M | Image per-pixel alpha map | `items/image.py:462-500 (ImageData.getAlphaData/setAlphaData, alternative image with alpha)` | DONE (GPU). Closed alongside primary row 254: `ImageData.alpha_map` + `ImageSpec::with_alpha_map` (scalar-only), a binding-5 R32Float alpha texture per tile (shared 1×1 dummy when absent) + a `has_alpha_map` flag in `ImageParams` (struct now 144 B), and an `image.wgsl` alpha-texture multiply into the global alpha on both the normal and nan_color paths, interpolation-matched. GPU-verified by `tests/image_alpha_map.rs` (left-opaque/right-transparent map vs an unmasked control). |
| ✅ | M | M | Image masking (per-pixel validity mask) | `items/image.py:209-251 (getMaskData/setMaskData), 273-284 (getValueData applies mask as NaN)` | `core/items.rs` `ScalarMask` (`get_mask_data`/`set_mask_data`/`apply` = getValueData→NaN); wired via `apply_image_mask` + `Plot2D::try_add_masked_image`. Masked pixels become `f32::NAN`, rendered transparent by the shader's non-finite → nan_color branch (`image.wgsl`, commit `0356d5c`). Tested. On-screen pixels GPU-unverified. (Same closure as row 153.) |
| ✅ | M | M | Marker draggability (isDraggable/drag callback) | `items/marker.py:52-67 (MarkerBase with DraggableMixIn), 177-206 (drag method, setPosition)` | Wave 11: a draggable marker (is_draggable) is grabbed on primary drag (topmost via marker_at), follows the cursor through Marker::drag (constraint-aware), persists to the backend item, and reports via PlotResponse.marker_moved / PlotEvent::MarkerMoved. Public set_marker_position / marker_position (silx setPosition/getPosition). No separate sigDragStarted/sigDragFinished split (single move signal). |
| ✅ | L | L | Image aggregation/downsampling modes (max/mean/min) | `items/image_aggregated.py:46-138 (ImageDataAggregated.Aggregation enum with NONE/MAX/MEAN/MIN, _getLevelOfDetails LOD reduction)` | `AggregationMode::{None, Max, Mean, Min}` (gpu_image.rs) + `aggregate_blocks(field, w, h, bx, by, mode)` reduce a field by integer block factors (silx `ImageDataAggregated` MAX/MEAN/MIN); wired as the image spec's `aggregation` + `aggregation_block` fields applied on the display path. Default None matches silx. Reducer tested (max/min/mean/none, odd dims). The aggregation mode + block factors are now retained in `RetainedItemData::Image` and restored (with origin/scale/alpha/interpolation) by the single-owner `ImageDisplayAttrs::apply` on every generic image re-upload path (level edit / autoscale / median filter / alpha-slider), closing a latent reset-to-default the prior per-field restore left open. |
| ✅ | L | L | Complex image modes (7 variants: absolute/phase/real/imaginary/amplitude_phase/log10_amplitude_phase/square_amplitude) | `items/complex.py:105-378 (ImageComplexData class, ComplexMode enum with 7 modes, mode-specific colormaps and conversions)` | `ComplexImageView` + `ComplexMode` (complex_image_view.rs) implement all 7 silx modes (Absolute/Phase/Real/Imaginary/SquareAmplitude/AmplitudePhase/Log10AmplitudePhase, plus an extra Log10Amplitude): scalar modes feed the colormapped pipeline, the amplitude-phase composites build HSV RGBA (hue=phase, value=normalized amplitude, with a settable log10 displayed-max). Conversions tested. |
| ✅ | L | L | Shape fill concavity (concave polygon fill) | `items/shape.py (silx defers to backend; matplotlib/pygfx fill concave correctly)` | Closed: `core::shape::triangulate_simple_polygon` (ear clipping, orientation-normalized, `N-2` triangles for a simple polygon, defensive fan on degenerate/self-intersecting input) lets the chrome fill `Shape`/`Roi` polygons as a triangulated `egui::Mesh` (`chrome::fill_polygon`) instead of egui's convex-only `Shape::convex_polygon`. Concave polygons now fill to their true outline, matching silx's matplotlib/pygfx backends (which fill concave correctly). Convex shapes (rect, ellipse 27-gon, band quad, diamond marker) are unchanged. 5 headless tests (triangle, convex quad, concave-tiles-exactly-not-hull, CW-input normalization, <3-points). Mesh draw GPU-painter-unverified. |
| ✅ | L | M | Image interpolation mode (nearest vs linear sampling) | `backends/BackendMatplotlib.py:805 (interpolation='nearest' hardcoded; silx backends via matplotlib accept 'nearest'/'bilinear'/etc.)` | `InterpolationMode::{Nearest, Linear}` (gpu_image.rs) is exposed as the `ImageData.interpolation` field (`with_interpolation` setter, default Nearest = silx) and carried into `image.wgsl` via `code()` (0/1) where the shader branches between nearest and bilinear scalar sampling. Mode plumbing tested; on-screen sampling GPU-unverified. The interpolation mode is now retained in `RetainedItemData::Image` and restored by `ImageDisplayAttrs::apply` across every generic image re-upload path (level edit / autoscale / median filter / alpha-slider), so a non-default `Linear` image keeps its sampling instead of silently reverting to `Nearest` on re-upload. |
| ✅ | L | M | Image stack (3D array, show one frame at a time) | `items/image.py:593-669 (ImageStack class, setStackData/getStackData/setStackPosition)` | `ImageStack` (image_stack.rs) browses an in-memory ordered list of 2D `Frame`s: a current-frame index driven by a first/prev/next/last slider (silx `setStackPosition`), a selectable frame table, per-frame visibility, a waiting overlay for empty slots, and display of the current frame through the `Plot2D` backend. Lazy URL/HDF5 frame loading is a separate gap (rows 1286-1287). Navigation state tested; render GPU-unverified. |
| ✅ | L | M | Marker constraint function (horizontal/vertical/custom drag filter) | `items/marker.py:208-235 (getConstraint/_setConstraint with _horizontalConstraint/_verticalConstraint), 273-292 (Marker subclass overrides for constraint strings)` | Wave 11 wired the horizontal/vertical presets (MarkerConstraint::Horizontal/Vertical) into the drag and the size cursor (Horizontal→SizeVer, Vertical→SizeHor). The arbitrary custom-callback form is now `Marker::drag_with(to, fn)` — silx's callable `constraint(x,y)->(x,y)`; `drag` delegates to it so both paths share one update owner. Closure is call-time state (keeps the Marker derives). |
| ✅ | L | M | Shape overlay flag (data layer vs separate overlay layer) | `items/shape.py:54-73 (_OverlayItem with isOverlay/setOverlay)` | `Shape::is_overlay`/`Line::is_overlay` (`with_overlay` setter) now drive a two-pass render: `draw_shapes`/`draw_lines` take an `overlay: bool` filter; `PlotWidget` draws non-overlay items in the base data layer (above grid, under ROIs/markers/crosshair) and overlay items in the overlay layer on top of the chrome (silx base-render vs `_drawOverlays`). Field/setter tested; render-layer split GPU/painter-unverified. |
| ✅ | L | M | Line item (infinite y = slope·x + intercept) | `items/shape.py:289-393 (Line class, distinct from Shape, infinite line with slope/intercept, computed vertices via visible bounds tracking)` | `Line` (core/shape.rs) is a distinct infinite-line item: `y = slope·x + intercept` (non-finite slope → vertical `x = intercept`), constructible from slope/intercept or two points (silx `setSlopeInterceptFromPoints`), clipped to the visible `[xmin, xmax]` bounds at draw time. Tested. |
| ✅ | L | S | Marker text anchor/alignment (positioning relative to marker point) | `backends/BackendMatplotlib.py:951-1047 addMarker + _TextWithOffset.__get_xy:304-340` | `TextAnchor` (9 variants) wired into `chrome::draw_markers` via pure `rect_offset` + `pixel_offset` (the latter ports silx's alignment-dependent offset sign). Per-kind silx defaults: point/vline `TopLeft` (`ha="left"`), hline `TopRight` (`ha="right",va="top"`, text at the right edge). Core tested headlessly; painter draw GPU-unverified |
## Backend Render (WgpuBackend vs BackendPygfx) — 38✅ 2◐ 0☐
The WgpuBackend implements the core Backend trait with substantial coverage of BackendPygfx: additive item methods (addCurve, addImage, addTriangles, addShape, addMarker with the full silx symbol set + drag constraints), state management (limits, axes, colors, margins), and core rendering (transforms, data-to-pixel, picking). `save_graph` exports PNG/PPM/TIFF/SVG (DPI accepted-but-ignored, matching silx). Remaining true gaps: async GPU stats/histogram reduction (functional autoscale/stats/histogram exist on CPU; the GPU-compute performance path is not mirrored). Several silx backend methods — setGraphGrid, setGraphCursor, time-series axis, postRedisplay — live at siplot's `Plot`/chrome/widget layer rather than the GPU backend: an architectural split, not missing features (marked ✅ N/A below).
| | P | E | Feature | silx | gap |
|---|---|---|---|---|---|
| ◐ | H | L | addMarker: point/hline/vline marker with symbol, text, line constraints | `BackendBase.py:211-267, BackendPygfx.py:1397-1465` | Markers (point/hline/vline) with symbol + text + drag constraints are implemented: `MarkerConstraint` (None/Horizontal/Vertical) + `apply_constraint` replace silx's Qt drag-filter callback (data-driven). Label background rect (silx `bgcolor`/`setBackgroundColor`) IS wired: `Marker::bgcolor` + `with_bgcolor`, rendered by `chrome::draw_marker_label` as a `rect_filled` behind the galley (`chrome.rs:1123-1129`), live via `draw_markers` (`plot_widget.rs:453`). Residual: custom text font (silx `setFont`/QFont) is not ported — marker labels render in a fixed 11 pt proportional font (egui `FontId` cannot express QFont weight/italic without registered font variants). |
| ✅ | H | M | Symbol support: circle, square, cross, plus, diamond, etc. | `BackendBase.py:116-126, BackendPygfx.py:85-98 (_SYMBOL_MAP)`, `items/core.py:731 (SymbolMixIn._SUPPORTED_SYMBOLS)` | One unified `items::Symbol` catalog covers the full 18-symbol silx `_SUPPORTED_SYMBOLS` set ('o'/'s'/'x'/'+'/'d'/'.'/','/'\|'/'_'/tickleft/tickright/tickup/tickdown/caretleft/caretright/caretup/caretdown/'♥') plus a Triangle egui-extra. **Markers share this exact catalog with curve vertices** — silx's `Marker` subclasses `SymbolSingleSizeMixIn(SymbolMixIn)`, so there is no separate marker-symbol set (the earlier 7-variant `MarkerSymbol` was a wrong-premise duplicate, now deleted). Curves draw via the GPU SDF shader (`markers.wgsl`, all 18 + heart cardioid); markers draw via the egui painter (`chrome::draw_marker_symbol`, all 18 cases at on-plot sizing); the legend icon painter (`high_level::draw_legend_symbol`) is the fixed-size analogue. |
| ✅ | M | L | saveGraph: PNG export at specified DPI and size | `BackendBase.py:372-382, BackendPygfx.py:2588-2610` | `save_graph`/`save_graph_with_format` + `SaveFormat` export PNG, PPM, TIFF, and SVG (`encode_png`/`encode_ppm`/`encode_tiff`/`encode_svg`). The DPI parameter is accepted-but-ignored, matching silx (noted there too). |
| ✅ | L | L | Line joins: round/bevel/miter for connected segments | `BackendPygfx.py uses pygfx LineMaterial (no explicit join config visible)` | **Round joins** (silx's pygfx `LineMaterial` default — verified against `~/codes/pygfx/.../line.wgsl`, which has no cap/join config and renders rounded). `GpuCurve::draw_caps` stamps an AA disc of the line width (`shaders/linecaps.wgsl`, sharing the marker bind-group layout) at every polyline vertex, filling the outer-wedge gap a sharp turn left between the butt-capped segment quads. Solid lines only (a disc at every vertex would fill a dash's gaps; dashes keep butt ends). pygfx's single-pass unified coverage avoids any geometry overlap; the disc approach double-composites at the segment overlap only for *translucent* lines (opaque lines — the dominant case — are exact, and the bare segments already overlapped at inner corners). GPU readback verified: `tests/curve_caps_joins.rs::solid_curve_has_round_joins_at_turns`. |
| ✅ | L | L | Line caps: butt, round, square at line endpoints | `BackendPygfx.py uses pygfx LineMaterial` | **Round caps** via the same per-vertex AA disc (`GpuCurve::draw_caps`): the disc at each endpoint extends the stroke into a half-disc, the pygfx `LineMaterial` default. A user-facing butt/round/square *selector* is not ported — silx/pygfx expose no such API on curves, so a selector would be a non-silx invention. GPU readback verified the cap extends ~half the width beyond the endpoint and is rounded, not square: `tests/curve_caps_joins.rs::solid_curve_has_round_caps_at_endpoints`. |
| ✅ N/A | L | L | Grid on/off: toggle major/both grid display | `BackendBase.py:543-549, BackendPygfx.py:2754-2756` | Resolved differently — grid display is `Plot::grid` (`GraphGrid::None`/`Major`/`MajorAndMinor`), drawn by the chrome layer. silx exposes `setGraphGrid` on the backend; siplot keeps it on `Plot`/chrome (the GPU backend draws data, not the grid) — an architectural split, not a missing feature. |
| ✅ N/A | L | M | Crosshair cursor (setGraphCursor): show/hide crosshair, set color/width/style | `BackendBase.py:289-310, BackendPygfx.py:1948-1990 (_updateCrosshair)` | Resolved at the widget layer — `Plot::crosshair`/`set_graph_cursor` toggle + `chrome::draw_crosshair` (`plot_widget.rs:457`) draw a crosshair + coordinate readout following the pointer (appearance via the chrome `Style`). A backend-trait method is, by design, not where siplot puts this. |
| ✅ N/A | L | M | Time-series axis: display datetime objects on X axis | `BackendBase.py:468-489, BackendPygfx.py:2704-2714` | Implemented at the chrome layer (`TickMode::TimeSeries` + `core/dtime_ticks`, wired in `chrome.rs`; see the PlotWidget-core TIME_SERIES row), not the GPU backend — the chrome-layer split the gap note itself describes ("chrome-layer responsibility in both silx and siplot"). |
| ◐ | L | M | GPU stats/histogram: async GPU compute for min/max/histogram (streaming data) | `BackendPygfx.py:1579-1622 (_WgpuComputeHelper, _AsyncCompute, _computeGpuDataStats, _computeGpuHistogram)` | The functional results exist on CPU — colormap autoscale (`AutoscaleMode::range`: MinMax/Stddev3/Percentile), curve/image stats (`core/stats`), and histograms (`pick_histogram`, `scatter_viz::binned_statistic`). silx's async GPU reduction (atomic + readback) for streaming-data performance is not mirrored. |
| ✅ N/A | L | S | postRedisplay / request_draw: request a repaint on next frame | `BackendBase.py:363-365 (postRedisplay calls replot)` | Resolved-not-a-gap — egui is immediate-mode and redraws every frame, so `postRedisplay`/`replot` has no consumer by design (same basis as the dirty/replot lifecycle rows). |
## Colormap, ColorBar, and AlphaSlider — 15✅ 0◐ 0☐
siplot implements colormaps with a 45-entry catalog covering silx's full named set (gray, reversed gray via `reversed()`, red, green, blue, temperature, jet, hsv, the viridis family, plus 30+ colorous gradients standing in for matplotlib's catalog), all 5 silx normalizations (Linear/Log/Sqrt/Gamma/Arcsinh), NaN-color configuration, and the three silx autoscale modes (MinMax/Stddev3/Percentile, with configurable percentile bounds via `AutoscaleMode::range`). The `ColorBarWidget` is a self-contained drawer with min/max labels, log/linear ticks, a rotated legend, and both vertical/horizontal orientations; the `ColormapDialog` adds name/normalization/gamma/autoscale/range controls plus a data-distribution histogram; an `AlphaSlider` widget exposes 0-255 opacity, and its item-binding subclasses (`ActiveImageAlphaSlider` / `NamedItemAlphaSlider`) drive a plot image's alpha — backed by image alpha now being retained and preserved across every image re-upload path. `Colormap` supports custom Nx4 LUTs (`from_colors`/`set_lut`), an editable flag, copy/equality, and `set_from`. The one documented model limitation is that the named alpha binding covers images only (curve/scatter carry no re-applicable per-item alpha here, so those sliders disable for them, matching silx's no-item behavior).
| | P | E | Feature | silx | gap |
|---|---|---|---|---|---|
| ✅ | H | L | Colormap catalog names | `/Users/stevek/codes/silx/src/silx/gui/colors.py:444-455, /Users/stevek/codes/silx/src/silx/math/colormap.py:53-65` | `ColormapName` is a 45-entry static catalog (`ALL`) covering silx's entire documented named set (`setName`: "at least" `gray`, `reversed gray`, `temperature`, `red`, `green`, `blue`, `jet`, `viridis`, `magma`, `inferno`, `plasma`). `Gray`/`Red`/`Green`/`Blue` are exact `single_channel_ramp`s, `Temperature` is transcribed channel-by-channel from `_create_colormap_lut`'s `arange` slices (`temperature_lut`, tested), `reversed gray` = `Colormap::reversed()` (LUT reverse), and `Jet`/`Hsv` + 30+ colorous gradients (Blues/Greens/Warm/Cool/Cubehelix/diverging/cyclical) stand in for matplotlib's catalog. Residual (not a gap in silx's contract): siplot ships a fixed static catalog rather than resolving arbitrary matplotlib names at runtime — it carries no matplotlib dependency (analogous to silx loading the viridis family from bundled `.npy` resources). |
| ✅ | H | M | Autoscale mode selection | `/Users/stevek/codes/silx/src/silx/gui/colors.py:318-331, 563-586` | `AutoscaleMode::{MinMax, Stddev3, Percentile}` (colormap.rs) with `AutoscaleMode::range(data, percentiles)` computing each mode faithfully — MinMax, mean±3·std clamped to data range (silx STDDEV3), and `nanpercentile` (silx PERCENTILE) — plus silx's empty/non-finite/inverted fallbacks. ColormapDialog exposes the mode selector. Tested. |
| ✅ | H | M | Autoscale percentile bounds | `/Users/stevek/codes/silx/src/silx/gui/colors.py:588-599, /Users/stevek/codes/silx/src/silx/math/colormap.py:355-368` | `Colormap.autoscale_percentiles: (f64, f64)` (default `DEFAULT_PERCENTILES = (1.0, 99.0)`) + `set_autoscale_percentiles` (silx `setAutoscalePercentiles`, each clamped to `[0,100]`, ordered low≤high); consumed by `AutoscaleMode::Percentile`. ColormapDialog has the low/high inputs. Tested. |
| ✅ | M | L | ColormapDialog interface | `/Users/stevek/codes/silx/src/silx/gui/dialog/ColormapDialog.py:24-300` | `widget/colormap_dialog.rs`: name (45 colormaps), normalization, vmin/vmax, gamma, AND the two cited gaps now closed — data-distribution histogram (`set_histogram`/`get_histogram` + auto-compute from the active image, per-norm cache; silx `setHistogram`/`_computeNormalizedHistogram`) drawn behind the range, plus autoscale-mode selector (`AutoscaleMode::{MinMax, Stddev3, Percentile}` = silx's full set, with `(low,high)` percentile inputs; silx `setAutoscaleMode`). |
| ✅ Done | M | M | NaN color configuration | `/Users/stevek/codes/silx/src/silx/gui/colors.py:506-518, 337` | `0356d5c`: `Colormap.nan_color` ([u8;4], default fully-transparent white #FFFFFF00) now reaches the GPU — `image.wgsl` detects non-finite samples and returns the sRGB→linear `nan_color` premultiplied by `alpha`, so NaN/masked pixels render transparent (also fixes ScalarMask, which sets masked pixels to f32::NAN). std140 size (128 B) + sRGB transfer unit-tested; GPU-visually unverified |
| ✅ | M | M | Colorbar drawing with ticks | `/Users/stevek/codes/silx/src/silx/gui/plot/ColorBar.py:296-920` | `widget/colorbar.rs` `ColorbarWidget`: 256-control-point gradient strip (silx `_NB_CONTROL_POINTS`), `paint_ticks_and_labels` draws major nice/decade ticks **with labels** plus minor sub-ticks (`tick_layout`/`optimal_nb_ticks` at `DEFAULT_TICK_DENSITY`), respecting the colormap normalization, in both vertical and horizontal orientations. |
| ✅ | M | M | Colorbar min/max labels | `/Users/stevek/codes/silx/src/silx/gui/plot/ColorBar.py:364-452` | `widget/colorbar.rs` `paint_end_labels` (silx `ColorScaleBar._updateMinMax`): formatted `vmax` at the top / `vmin` at the bottom (vertical) or left/right (horizontal) via `format_end_label`. The Qt hover-tooltip-with-full-precision is a Qt-widget affordance not applicable to a painted egui widget; the formatted end labels themselves are present. |
| ✅ | M | S | Normalization types | `/Users/stevek/codes/silx/src/silx/gui/colors.py:292-316` | All 5 present: `Normalization::{Linear, Log, Sqrt, Gamma, Arcsinh}` (`core/colormap.rs:19-35`). Arcsinh (silx `ARCSINH`) transforms `v.asinh()` on the CPU path (`:63`, `code()==4`), in `image.wgsl` (`norm==4u`, `asinh(raw)`), and in the colorbar tick layout (`colorbar.rs:857`); tested (`colormap.rs:1144-1169`). |
| ✅ | L | L | ColorbarWidget standalone | `/Users/stevek/codes/silx/src/silx/gui/plot/ColorBar.py:44-263` | `ColorBarWidget` (colorbar.rs) is a self-contained, exported widget: `new(colormap, vmin, vmax)` + `with_legend`/`with_orientation` builders, drawing the gradient strip, ticks/labels, end labels, and legend into any allotted rect. Caller syncs it to the active item's colormap (the silx signal-sync is the host's job). |
| ✅ | L | M | Colorbar legend label | `/Users/stevek/codes/silx/src/silx/gui/plot/ColorBar.py:44-200, 266-294` | `ColorBarWidget.legend` + `with_legend` + `paint_legend` draw a rotated title running along the bar (silx `_VerticalLegend`, 270°); empty legend hides the strip (`split_legend`). |
| ✅ | L | M | Colorbar orientation (vertical/horizontal) | `/Users/stevek/codes/silx/src/silx/gui/plot/ColorBar.py (vertical only in examples but widget is orientation-agnostic)` | `ColorBarOrientation::{Vertical, Horizontal}` is a `ColorBarWidget` field (`with_orientation` builder, default Vertical); the bar, ticks, end labels, and legend strip all branch on it (silx Qt::Vertical/Qt::Horizontal). |
| ✅ | L | M | AlphaSlider widget | `/Users/stevek/codes/silx/src/silx/gui/plot/AlphaSlider.py:86-300` | The base `AlphaSlider` (alpha_slider.rs) is a 0..=255 opacity slider exposing `u8`/`f32` alpha (`value/255`, silx `valueChanged`) with `AlphaSliderOrientation` and a `ui() -> Response`; the item-binding subclasses are now wired against the plot model: `ActiveImageAlphaSlider` (silx `ActiveImageAlphaSlider`, `getItem()=getActiveImage()`) and `NamedItemAlphaSlider` (silx `NamedImageAlphaSlider`, an image by legend) seed from the bound image's alpha, disable when there is no such image (silx "getItem() → None disables"), and write each change back through the new `PlotWidget::set_image_alpha`/`set_active_image_alpha`. The enabling structural change: image alpha is now retained in `RetainedItemData::Image` (single owner `image_spec_retained_data`) and preserved across every re-upload path — level edit, raw-pixel autoscale, median filter — which previously reset it to the `ImageSpec::scalar` default of `1.0`. Verified by 9 tests (`tests/alpha_slider_binding.rs`): retention across all three re-upload paths, the per-item alpha API, slider seeding/disabling, and a kittest drag writing alpha back to the image. Documented model limitation (not a silx-contract gap closed here): silx's curve/scatter *named* bindings are not mirrored — siplot retains a re-applicable per-item alpha only for scalar images (a curve bakes opacity into its color; a scatter retains no data), so those named sliders correctly disable rather than drive nothing. |
| ✅ | L | M | Custom colormap registration | `/Users/stevek/codes/silx/src/silx/math/colormap.py:158-176, /Users/stevek/codes/silx/src/silx/gui/colors.py:343-386` | Per-instance custom LUTs (`Colormap::from_colors(&[[u8;4]], vmin, vmax)`, `with_lut`/`set_lut`, silx `setColormapLUT`) AND a process-global named-LUT registry: `register_colormap(name, &[[u8;4]], cursor_color: Option)` (silx `registerLUT`/`register_colormap` — resamples via `resample_lut`, overriding allowed, empty colors → `false`), `Colormap::from_registered(name, vmin, vmax)` resolves a registered name to its LUT (silx `Colormap(name=…)`), `registered_colormaps()` (silx `get_registered_colormaps`), `registered_colormap_cursor_color()` (silx `get_colormap_cursor_color`). Backed by a `OnceLock<RwLock<BTreeMap>>`, the siplot analogue of silx's module-level `_AVAILABLE_LUTS`/`_COLORMAP_CACHE`. 5 headless tests. |
| ✅ | L | S | Colormap copy/comparison/serialization | `/Users/stevek/codes/silx/src/silx/gui/colors.py:399-423, 960-1050` | `Colormap` derives Clone (copy) + PartialEq (`__eq__`) and has `set_from` (silx `setFromColormap`, editable-guarded). State round-trip now ported in `core::colormap_io` (silx `saveState`/`restoreState`): `encode_colormap`/`decode_colormap` (hand-written line-oriented text, no serde — same approach as `roi_io` and the `.npy` mask path) + `save_colormap`/`load_colormap` file wrappers. Because siplot's `Colormap` stores a resolved LUT and keeps no name (unlike silx, which serializes the name + registry), the state serializes the LUT itself → lossless `decode(encode(c)) == c` for catalog, `from_colors`, and registered LUTs alike; `lut` is the only required line, every other field defaults when absent (silx version-gated `restoreState` defaults), unknown keys ignored. 8 headless tests. |
| ✅ | L | S | Colormap editability flag | `/Users/stevek/codes/silx/src/silx/gui/colors.py:351, 659-674` | `Colormap.editable: bool` (default true) with `is_editable`/`set_editable` (silx `setEditable`); the editable-guarded setters (`set_lut`, `set_autoscale_percentiles`, `set_from`) become no-ops returning `false` when frozen (silx `NotEditableError`). |
## ROI system (creation, editing, manager, statistics) — 29✅ 1◐ 0☐
siplot implements a ROI core with all 11 silx shape kinds (Rect, HRange, VRange, Point, Line, Polygon, Cross, Circle, Ellipse [oriented], Arc, Band), supporting on-plot interactive creation for every kind (Wave 12: arm a draw shape, draw it, the ROI is appended), edge dragging via handle-detection, and whole-ROI translate — all in pure data space. Wave 13 unified `plot.rois` to a single `Vec<ManagedRoi>` (geometry + appearance), closing the visual/behavioral gap: per-ROI color, on-canvas naming/labels, selection highlighting (single-owner `Plot::set_current_roi` + `sigCurrentRoiChanged`), handle-symbol roles, and style properties (line width, style, gap color, fill) all reach the live plot; `Roi::contains()` is implemented for every variant; and ROI statistics (curve raw/net counts + area, image/curve stats tables via CurvesROIWidget/ROIStatsWidget) landed in W15. The per-shape interaction sub-modes are now wired end-to-end: the right-click "Interaction mode" submenu picks the mode, the Band Bounded↔Unbounded toggle drives the live render (`chrome::draw_roi`), and the Arc PolarMode↔ThreePointMode toggle drives the live handle drag (`roi_apply_edge_drag` routes to `move_edge` vs `arc_three_point_drag`, with the handle set gated to match). The manager is now a real table widget (`show_roi_manager`, silx `RegionOfInterestTableWidget`): per-row editable name + geometry make-current selector + remove, with per-ROI stats columns supplied by the sibling `CurvesRoiWidget`/`RoiStatsWidget` tables — GPU-verified by an egui_kittest interaction test (`tests/roi_manager_render.rs`). ROI persistence/load is complete: `core::roi_io` text serialization + `PlotWidget::save_rois_to_path`/`load_rois_from_path` + native `save_rois_dialog`/`load_rois_dialog`.
| | P | E | Feature | silx | gap |
|---|---|---|---|---|---|
| ✅ | H | L | Interactive ROI creation mode (draw mode vs select mode) | `tools/roi.py:833-885 (start/stop/isStarted/isDrawing)` | Wave 12: `PlotInteractionMode::RoiCreate(RoiDrawKind)` arms a draw shape (silx `start(roiClass)`); the existing `DrawState` machine drives the gesture, `roi_from_draw` maps the finished `DrawParams`→`Roi` (silx `setFirstShapePoints`), the ROI is appended, the mode re-arms continuously (silx default), and a live preview overlay is surfaced during the gesture. Public `set_roi_create_mode(kind)` toggles draw vs select. Still missing the toolbar `CreateRoiModeAction`/mode-selection widget. On-screen creation is GPU/PlotWidget-UNVERIFIED; the `DrawParams`→`Roi` mapping and the `apply_interaction` wiring are headlessly unit-tested. |
| ✅ | H | L | Manager ROI list as table widget (ROITable/CurvesROIWidget) | `CurvesROIWidget.py:62-400, ROITable:452-860` | `PlotWidget::show_roi_manager` is now an `egui::Grid` table (silx `RegionOfInterestTableWidget`): one row per ROI with an **editable name** (silx label column → `set_roi_name`), the **geometry as a make-current selector** (silx row selection → `set_current_roi`/`sigCurrentRoiChanged`, highlighted when current), and a remove button, plus the add-kind / clear-all controls. Per-row edits are collected under the immutable `rois` borrow and applied through the owner APIs after it ends (events fire, current-index stays consistent). The complementary **per-ROI stats columns** (min/max/sum/mean/integral, raw/net counts/area, COM/coords) are provided by the sibling `CurvesRoiWidget`/`RoiStatsWidget` tables (rows 1160-1162). GPU-verified by a headless egui_kittest interaction test (`tests/roi_manager_render.rs`): the table renders a row per ROI and clicking a row's geometry selector makes that ROI current. |
| ✅ | H | L | ROI statistics (min/max/mean/sum/integral, raw/net counts, COM/coords) | `CurvesROIWidget.py:355-430, ROIStatsWidget.py (full file)` | `roi_stats` computes count/min/max/mean/sum/integral per ROI (image + curve) plus center-of-mass and first-min/first-max **data coordinates** (silx `StatCOM`/`StatCoordMin`/`StatCoordMax`): value-weighted COM, first-extremum tie-break matching numpy argmin/argmax, 2D `(x, y)` for image ROIs / x-only for curve ROIs. `curve_roi_counts` adds curve raw/net counts + raw/net area (silx `computeRawAndNetCounts/Area`); `RoiStatsWidget` renders all columns incl. COM / coord min / coord max. "Peaks" was a misattribution — not a silx ROI stat (peak-search lives in the fit cluster, `peak_search`); neither `CurvesROIWidget` nor the `stats` framework (`StatMin/Max/Delta/CoordMin/CoordMax/COM`) computes peaks. Reductions are pure and headlessly tested (image value-weighted COM = (13/6, 8/3) on the 4×4 ramp with coord min/max; curve x-only COM + coords); the widget table is GPU-unverified. |
| ✅ | H | L | CurvesROIWidget integration (ROI stats per curve item) | `CurvesROIWidget.py (entire file)` | W15: `CurvesRoiWidget` shows a per-ROI raw/net counts + raw/net area table over the active curve via `PlotWidget::feed_curves_roi_stats`/`show_curves_roi_widget`, reducing each x-span ROI with the pure `curve_roi_counts` (silx `computeRawAndNetCounts`/`computeRawAndNetArea`). |
| ✅ | H | L | ROIStatsWidget (image/curve ROI stats display) | `ROIStatsWidget.py (entire file)` | W15: `RoiStatsWidget` shows a per-ROI stats table (`ROI \| N \| min \| max \| mean \| sum \| integral`) over the active item via `PlotWidget::feed_roi_stats`/`show_roi_stats_widget`, reducing each ROI with `image_roi_stats`/`curve_roi_stats`. |
| ✅ | H | M | ROI per-instance color (independent from manager default) | `items/_roi_base.py:389-405, tools/roi.py:713-742` | Wave 13: `ManagedRoi.color: Option<Color32>` (silx `setColor`/`getColor`); the live `chrome::draw_rois` path resolves `managed.color.unwrap_or(plot.roi_color)` via pure `chrome::roi_appearance`; high-level `set_roi_color` + manager color buttons write the per-ROI override. Appearance resolution headlessly tested; on-screen pixels GPU-unverified. |
| ✅ | H | M | ROI label/text display on canvas | `items/_roi_base.py:492-511` | Wave 13: `ManagedRoi.name` flows through `chrome::roi_appearance.name` into `draw_roi`, which renders the name as a label at the ROI anchor (chrome.rs:1006); empty name → no label. High-level `set_roi_name` + manager name editor. Resolution tested; label pixels GPU-unverified. |
| ✅ | H | M | ROI selection/highlighting (visual feedback) | `tools/roi.py:528-590 (setCurrentRoi/getCurrentRoi/sigCurrentRoiChanged)` | Wave 13: `Plot::set_current_roi` (plot.rs:518) is the sole writer of `ManagedRoi.selected` (silx `setHighlighted`); `draw_roi` thickens the selected ROI's stroke (chrome.rs:782). Single-owner invariant (exactly one selected; out-of-range clears; remove adjusts index) headlessly tested; highlight pixels GPU-unverified. |
| ✅ | H | M | ROI contains() point-in-region test | `items/roi.py:61-1599 (all ROI classes have contains method)` | `Roi::contains((x,y))` implemented for every variant (roi.rs:838): Rect/HRange/VRange inclusive bbox, HLine/VLine/Point exact-coord, Cross x\|y, Line unit-square intersection, Polygon/Band point-in-polygon, Circle dist≤r, oriented-Ellipse projection, Arc ring+sweep (`arc_contains`). 13 headless tests (rect/band/point/cross/circle/ellipse×2/polygon/line/arc×2). |
| ✅ | H | M | Manager current ROI tracking (setCurrentRoi/getCurrentRoi) | `tools/roi.py:528-590` | Wave 13: `Plot::current_roi`/`set_current_roi` (plot.rs:509/518) own the selected-ROI index; `RoiManagerWidget` reads `plot.current_roi()` and writes via `plot.set_current_roi()` (no own state); `PlotEvent::CurrentRoiChanged { previous, current }` mirrors silx `sigCurrentRoiChanged`. Selection invariant tested. |
| ✅ | H | S | ROI name/label (string identifier) | `items/_roi_base.py:77-92, CurvesROIWidget.py:594-640` | `ManagedRoi.name: String` (silx `getName`/`setName`); high-level `set_roi_name` and the manager's per-row name editor write it; resolved into the canvas label (row above). |
| ✅ | M | L | EllipseROI kind (center, radii, orientation handles) | `items/roi.py:950-1177` | `Roi::Ellipse { center, radii, orientation }` with on-plot creation (`RoiDrawKind::Ellipse`) and full rotational geometry: the two axis handles sit at θ / θ+π/2 and dragging one off-axis sets that semi-axis and rotates (silx `EllipseROI.handleDragUpdated`); `contains`, `screen_rect`, and the outline are all orientation-aware. Models `EllipseROI._orientation` as `orientation` (radii.0 along it, radii.1 perpendicular). |
| ✅ Done | M | M | CrossROI kind (point with cross marker) | `items/roi.py:133-185` | `Roi::Cross { center }` exists with Wave 12 on-plot creation (`RoiDrawKind::Cross`, single click via `DrawMode::Point`); `1cbda70` makes the drag handle a square (silx `addHandle()` default symbol "s") via `kind()` returning a `Vertex`. silx's transient label-handle management remains a manager-path concern (same as row 173). |
| ✅ | M | M | CircleROI kind (center + radius handles) | `items/roi.py:800-950` | `Roi::Circle { center, radius }` with on-plot creation (`RoiDrawKind::Circle`) AND dedicated edit handles: `edges()` → `[Vertex(0) center, Vertex(1) perimeter]`, `move_edge` (V0 translate, V1 sets radius), rendered+hit-tested via `draw_roi_handles`/`roi_grab_at`. Tests `circle_handles_drag_center_and_radius`, `circle_perimeter_resize_works_under_inverted_y`. |
| ✅ | M | M | ROI handle symbols ('+', 's', 'o', custom glyphs) | `items/_roi_base.py:735-759 (addHandle/addLabelHandle/addTranslateHandle), PolygonROI:1244-1274` | `draw_roi_handles` draws the silx role symbols: `+` for translate/center, square `s` for vertex/edge. The `o` is silx `PolygonROI._handleClose` (a transient role-"user" creation marker from the `RegionOfInterestManager` path); siplot creates polygons via the `PlotInteraction` draw path and shows its faithful close target instead — the `SelectPolygon.updateFirstPoint` fill=None box. |
| ✅ | M | M | ROI line style (solid, dash, dot, gap color) | `items/_roi_base.py:600-680, RectangleROI:534-552` | `ManagedRoi.line_style` (`RoiLineStyle::{Solid,Dashed,Dotted}` → `LineStyle`) emits real dash/dot segments via `chrome::draw_styled_line`; `ManagedRoi.gap_color` fills dash gaps (silx `setLineGapColor`, commit `a6298b9`). Manager UI exposes style combo + gap swatch; `set_roi_line_style`/`set_roi_line_gap_color` setters. GPU-unverified on-screen. |
| ◐ | M | M | Manager signals (sigRoiAdded, sigRoiChanged, sigCurrentRoiChanged, sigInteractiveRoiCreated/Finalized) | `tools/roi.py:331-371` | Now distinct: `RoiAdded { index }` (silx `sigRoiAdded`) fires from every add — `add_roi`/`add_managed_roi` and the interactive create (which emits `RoiAdded` then `RoiCreated`, silx's `sigRoiAdded`→`sigInteractiveRoiFinalized` order). `RoiChanged` (silx `sigRoiChanged`) is now geometry-change-only; `CurrentRoiChanged` = `sigCurrentRoiChanged`; `RoiCreated` = `sigInteractiveRoiFinalized` (draw finished). `RoiAboutToBeRemoved { index }` (silx `sigRoiAboutToBeRemoved`) is now emitted by `remove_roi` *before* the removal (so a listener can still read the dropped ROI); `RoisCleared` is reserved for `clear_rois` (clear-all) — removing the prior dual meaning where a single removal also emitted `RoisCleared`. Regression-tested in `tests/roi_events.rs` (3 cases: about-to-be-removed-before-removal + not-RoisCleared, clear-all, out-of-range no-op). Remaining ◐: no separate `sigInteractiveRoiCreated` (mid-gesture) — siplot builds the ROI only on draw-finish, so there is no mid-draw ROI object to signal (the rubber-band emits `DrawingProgress` instead). |
| ✅ | M | M | ROI creation phase UI (preview overlay, mode indicator, close-polygon handle) | `PolygonROI:1241-1256, tools/roi.py:493-510, 1101-1175` | All three present: (1) live preview overlay during the draw gesture (`Interaction.roi_preview` → `draw_overlay`); (2) the polygon 'close' target — `paint_draw_preview` draws `draw_polygon_first_point` (silx `SelectPolygon.updateFirstPoint` fill=None box) around the first vertex while drawing a polygon; (3) mode indicator — `PlotInteractionMode::roi_creation_message(roi_count)` / `PlotWidget::roi_creation_message()` produce silx's `InteractiveRegionOfInterestManager.getMessage` string ("Select {SHORT_NAME}s ({n} selected)") while a create mode is armed, for a host status bar (silx's status message is app-displayed too). Message + `RoiDrawKind::short_name` are headlessly tested; the on-screen preview/close-target render is GPU-UNVERIFIED. |
| ✅ | M | S | ROI line width customization | `items/_roi_base.py:600-680, RectangleROI:534-552` | `ManagedRoi.line_width` (default 1.0) → `RoiAppearance.line_width` applied as the stroke width (selected → `max(w, 2)`); manager UI `DragValue` (0.1..=20.0); `set_roi_line_width` setter. |
| ✅ | M | S | ROI fill color and fill enable/disable | `items/Shape.py (shape draw), RectangleROI:531-539` | `ManagedRoi.fill` (default false) → `RoiAppearance.fill`; `Some(false)` draws no interior (silx `setFill(False)`), else the translucent tint at the ROI color (matches silx, which fills with the ROI color). Manager UI fill checkbox; `set_roi_fill` setter. Fill color is not separately configurable (derives from outline color), matching silx. |
| ✅ | M | S | Manager ROI color default (setColor/getColor) | `tools/roi.py:782-797` | `Plot::roi_color` (silx default red) is the per-ROI fallback in `draw_rois(.., default_color, ..)`/`roi_appearance` when `managed.color` is None; manager rows show the resolved color and write per-ROI overrides. |
| ✅ | M | S | ROI context menu (edit, delete, mode selection) | `tools/roi.py:625-642` | Right-clicking a ROI on the canvas opens a per-ROI menu (silx `_createMenuForRoi`): ROI-name title, "Make current" (→ `set_current_roi`, silx `sigCurrentRoiChanged`), "Remove" (→ `remove_roi`), and — for `InteractionModeMixIn` ROIs (Arc/Band) — a nested "Interaction mode" submenu (silx `createMenuForInteractionMode`) of the ROI's `available_interaction_modes`, the current one radio-checked, each tooltipped with its silx description, applied via `set_roi_interaction_mode` → `RoiInteractionModeChanged`. The target ROI is captured at right-click via `roi_grab_at` and held in `ui.data` for the menu's lifetime; the canvas only signals intent (`Interaction.roi_removed`/`roi_make_current`/`roi_set_interaction_mode`) and the high-level owner performs the mutation + event. "edit" = on-canvas handle drag (Wave 12, separate). The mode-change API + event are GPU-backed-tested in `tests/roi_events.rs`; the menu render itself is GPU-unverified. |
| ✅ | L | L | ArcROI kind (circular arc with start/end angle) | `items/_arc_roi.py (full file)` | `Roi::Arc { center, inner/outer radius, start/end angle }` exists; Wave 12 adds on-plot creation (`RoiDrawKind::Arc`, 2-point line drag) and PolarMode handle editing (mid/weight/start/end). The ThreePointMode geometry is ported too — `arc_from_three_points` (faithful `_createGeometryFromControlPoints`), `arc_control_points`, and `arc_three_point_drag` (control-point reshape, re-fits the circumcircle, preserves weight); `arc_from_two_points` delegates to it. The mode is selectable via the right-click "Interaction mode" submenu + `set_roi_interaction_mode` (row 1179), and the live handle drag now dispatches on it (`roi_apply_edge_drag`): a ThreePointMode Arc shows/hit-tests/reshapes the three control points, PolarMode the four polar handles. 8 headless tests for the Arc sub-mode model + dispatch; handle glyphs GPU-unverified. |
| ✅ | L | L | BandROI kind (rotatable rectangular band) | `items/_band_roi.py (full file)` | `Roi::Band { begin, end, width }` exists with on-plot creation (`RoiDrawKind::Band`) and silx's width constraints (`width>=0`, width handles constrained to the band normal). Orientation is free via the begin/end handles (silx has no separate rotate handle). silx's real sub-modes are `BoundedMode` (polygon, siplot default) and `UnboundedMode` (three infinite lines) — the Unbounded geometry is `Roi::band_lines()` (faithful `BandGeometry` line equations, `Sloped`/`Vertical` sum type), spanned to the visible view by the pure `Roi::band_unbounded_segments(x_range, y_range)` (3 view-clipped segments, tested headlessly). The mode is selectable via the right-click "Interaction mode" submenu + `set_roi_interaction_mode` (row 1179), and `chrome::draw_roi` now swaps the bounded polygon for the three view-spanning lines when the band's `interaction_mode` is `BandUnbounded`. The swap is GPU-verified by a headless wgpu render test (`tests/roi_band_unbounded_render.rs`): UnboundedMode's lines reach the data-area left edge while BoundedMode's polygon stays at the band's `x∈[5,9]` extent. |
| ✅ | L | M | HorizontalLineROI kind (single y coordinate) | `items/roi.py:373-435` | `Roi::HLine { y }` is a single-y full-span line, distinct from the `HRange` band: one edge (the line's y), one handle, `contains` = exact-y. `RoiDrawKind::HLine`→`DrawMode::HLine` (silx `SelectHLine` `_plotShape="hline"`), `roi_from_draw` captures the row; chrome draws it as a styled full-width line. Geometry/grab/draw-build tested; GPU draw unverified |
| ✅ | L | M | VerticalLineROI kind (single x coordinate) | `items/roi.py:437-510` | `Roi::VLine { x }` analogue: single-x full-span line, one edge (the line's x), exact-x `contains`. `RoiDrawKind::VLine`→`DrawMode::VLine` (silx `SelectVLine`). Tested; GPU draw unverified |
| ✅ | L | M | ROI interaction modes (select, edit, focus constraints) | `items/_roi_base.py:135-230 (InteractionModeMixIn, RoiInteractionMode enum)` | Wave 12 adds a select-vs-create mode toggle and, within editing, both edge-drag and whole-ROI translate (`RoiGrab::Edge`/`RoiGrab::Translate`). The silx `InteractionModeMixIn` data model is now ported: `RoiInteractionMode` (Arc `3 points`/`Polar`/`Translation`, Band `Bounded`/`Unbounded`, with silx labels + descriptions), `Roi::available_interaction_modes`/`default_interaction_mode`, and a validated `ManagedRoi::interaction_mode`/`set_interaction_mode` that rejects modes foreign to the kind — so the invariant `mode ∈ available_modes` holds by construction; `ManagedRoi::new` seeds the silx default per kind. 5 headless tests. The mode is now selectable through the right-click "Interaction mode" submenu + `PlotWidget::set_roi_interaction_mode` (→ `RoiInteractionModeChanged`, row 1179). The Band `Bounded`↔`Unbounded` mode drives the live render (row 1181 — `chrome::draw_roi` swaps polygon ↔ three view-spanning lines), and the Arc `3 points`↔`Polar` live handle drag now dispatches on the selected mode (row 1180): `roi_apply_edge_drag` is the single owner that routes a grabbed handle to `arc_three_point_drag` (ThreePointMode, three control-point handles) or `Roi::move_edge` (PolarMode + every other ROI), with the handle set (`arc_three_point_handles`/`arc_three_point_grab`) and `chrome::draw_roi_handles` gated to match. Headlessly tested (grab + dispatch + handle list); the on-screen handle glyphs and the menu render are GPU-unverified. |
| ✅ | L | M | ROI edge position constraints | `items/roi.py:666-718 (RectangleROI), _arc_roi.py:889, _band_roi.py:66,383-392` | All silx-real edge constraints are enforced in `move_edge`: Rect/Range handles flip around the fixed opposite (silx min/max `_setBound`) rather than collapsing; Arc keeps `inner<=outer`/`inner>=0`; Band keeps `width>=0` + the orthogonal width-handle constraint. silx has no ratio-lock, grid-snapping, or inter-ROI collision/overlap prevention (the "Band preventing overlap" was a mis-attribution — `_band_roi.py` only clamps width and constrains width handles to the normal). |
| ✅ | L | M | ROI save/load from file (dictdump format) | `CurvesROIWidget.py:194-210, roi save/load methods` | Data layer + widget wiring ported: `core::roi_io` `encode_rois`/`decode_rois` (hand-written line-oriented text, no serde — mirrors the `.npy` mask path) round-trip every `Roi` variant + name/color/line width+style/gap color/fill (the transient `selected` is dropped, as silx `ROI.toDict` does), with `save_rois`/`load_rois` file wrappers (silx `CurvesROIWidget.save`/`load(filename)`). `PlotWidget::save_rois_to_path`/`load_rois_from_path` are the testable widget-level wrappers — load replaces the live set via `clear_rois` then one `add_managed_roi` per ROI, so it emits `RoisCleared` + N×`RoiAdded`; `save_rois_dialog`/`load_rois_dialog` open the native rfd file picker, mirroring the figure `save_dialog` (row 236). 10 tests: 9 `roi_io` round-trip/error cases + a GPU-backed `PlotWidget` save→mutate→load round-trip in `tests/roi_events.rs` asserting the replaced set + the cleared/added events. The rfd dialogs are GPU/UI-unverified shims, as with every native dialog. |
| ✅ | L | S | ROI keyboard shortcuts / text input for named creation | `tools/roi.py:1072-1098 (eventFilter)` | Ported the real silx ROI key bindings via pure `roi_key_action`: Enter→Validate (close polygon), Delete/Backspace/Ctrl+Z→UndoLast (`removeRoi(rois[-1])`). silx has no key→shape map and no named-creation dialog (those were speculative); ROI naming is via the manager (`ManagedRoi.name`). Wired into the RoiCreate block; mapping headlessly tested. |
## Mask tools (image + scatter) — 27✅ 0◐ 0☐
siplot's mask tools are comprehensive. `MaskToolsWidget` (image) and `ScatterMaskWidget` (scatter points by index) both implement multi-level masks (0 unmasked, 1..=255 levels), the full drawing-tool set (pencil/eraser brush with Bresenham line interpolation + variable width, rectangle, polygon, ellipse), threshold masking (below/between/above), NaN masking, per-level invert/clear, undo/redo history (silx `BaseMask`), per-level color overrides, a transparency slider, the Mask/Unmask + Ctrl toggle, and a pan/browse (no-draw) tool — all rendered by `show_toolbar` and faithful to silx `_BaseMaskToolsWidget`. The colormapped overlay (`mask_overlay_lut`) renders the masked pixels. Mask file save/load now cover ALL five silx formats — `.npy`, `.edf`, `.tif`/`.tiff`, `.h5` (HDF5, pure-Rust `rust-hdf5`), and fit2d `.msk` (binary bit-pack, byte-verified against fabio) — via `write_*`/`read_*` + `save_*`/`load_*` wrappers sharing the `apply_loaded_mask` crop/pad owner. `show_toolbar` renders silx's Load.../Save... actions opening the native rfd file dialog; HDF5 save is faithful to silx's "a" append mode (preserve other datasets, overwrite only the named one), and the "Select a 2D dataset" picker is ported as an egui modal (`begin_h5_load`/`begin_h5_save` → modal → `load_h5_dataset`/`save_h5_dataset`), GPU-verified by `tests/mask_h5_picker.rs`. The only unreproduced silx detail is the secondary "overwrite existing dataset?" confirmation QMessageBox.
| | P | E | Feature | silx | gap |
|---|---|---|---|---|---|
| ✅ | H | L | Multi-level masks (1-255 levels) | `_BaseMaskToolsWidget.py:279, 583-585` | `MaskToolsWidget` masks a per-pixel `u8` grid: 0 unmasked, 1..=255 are up to 254 non-overlapping levels; drawing writes the current `level` (highlighted at full alpha, others half). `ScatterMaskWidget` is the 1D analogue. Tested. |
| ✅ | H | L | Scatter mask variant (1D point masking) | `ScatterMaskToolsWidget.py (entire file, 643 lines)` | `ScatterMaskWidget` (scatter_mask.rs): a multi-level mask over scatter points by index (1D `u8` buffer, one per point), same level/invert/clear/undo semantics as the image mask but over point coordinates (silx `ScatterMaskToolsWidget`). Tested. |
| ✅ | H | M | Drawing tool: Rectangle | `MaskToolsWidget.py:805-826, _BaseMaskToolsWidget.py:307-317, shapes.polygon_fill_mask` | `MaskTool::Rectangle` → `DrawMode::Rectangle`; `handle_shape_draw` + `rect_params_to_cells` (silx int truncation) → `update_rectangle` on finish. Live row 194. Tested. |
| ✅ | H | M | Drawing tool: Polygon | `MaskToolsWidget.py:840-847, _BaseMaskToolsWidget.py:319-326, shapes.polygon_fill_mask` | `MaskTool::Polygon` → click-to-add/snap-close draw; `polygon_vertices_to_cells` + `polygon_fill_mask` (silx `(y,x)` swap) → `update_polygon` on finish. Live row 195. Tested. |
| ✅ | H | M | Drawing tool: Ellipse | `MaskToolsWidget.py:828-838, _BaseMaskToolsWidget.py:351-361, shapes.ellipse_fill` | `MaskTool::Ellipse` → `ellipse_params_to_cells` (center int, y-semi→radius_r, x-semi→radius_c) → `update_ellipse` on finish. Live row 196. Tested. |
| ✅ | H | M | Undo/Redo history | `_BaseMaskToolsWidget.py:144-194 (resetHistory, commit, undo, redo), 609-629 (undo/redo actions)` | `MaskHistory` (bounded snapshot stack, default depth = silx `historyDepth`) with `commit`/`undo`/`redo` mirroring silx `BaseMask` (`_history`/`_redo`, redo cleared on commit, undo needs >1 snapshot); `can_undo`/`can_redo` = silx `sigUndoable`/`sigRedoable`. Tested. |
| ✅ | H | M | Threshold-based masking (below, between, above) | `_BaseMaskToolsWidget.py:265-294 (updateBelowThreshold, updateBetweenThresholds, updateAboveThreshold), 848-937 (threshold UI), 1204-1225 (apply handler)` | `ThresholdMode::{Below, Between, Above}` + `update_threshold` (below→data<min, between→min≤data≤max, above→data>max) at the current level; ImageView toolbar has the mode selector + min/max fields + Apply. Live row 199. Tested. |
| ✅ | H | M | Mask save to file (npy/edf/tif/h5/msk) | `MaskToolsWidget.py:104-141 (save method: edf/tif/npy/h5/msk), 698-785 (_saveMask dialog and format handling)` | ALL five formats: `write_npy` (2D `uint8` `.npy`), `write_edf` (fabio-style EDF), `write_tiff` (grayscale `uint8` TIFF), `save_h5`/`save_h5_dataset` (HDF5 dataset via pure-Rust `rust-hdf5`), `write_msk` (fit2d binary, `encode_mask_msk` byte-verified vs fabio) — each with `save_*`/`save_mask_*` wrappers. The file dialog is wired (`save_mask_dialog`, native rfd, npy/edf/tiff/hdf5/msk, extension-dispatched) behind the `show_toolbar` Save... button (silx `saveAction`). HDF5 save is faithful to silx's "a" mode (`open_rw` append + delete-and-recreate the named dataset, else create) and the "Select a 2D dataset" save picker is ported (`begin_h5_save` → `show_toolbar` modal → `save_h5_dataset`; default `mask`, existing datasets as overwrite shortcuts). GPU-verified by `tests/mask_h5_picker.rs`. Deviation: the secondary "overwrite existing dataset?" QMessageBox is not reproduced. Live row 200. |
| ✅ | H | M | Mask load from file (npy/edf/tif/h5/msk) | `MaskToolsWidget.py:589-629 (load method), 630-677 (_loadMask dialog)` | ALL five formats: `read_npy`, `read_edf`, `read_tiff`, `load_h5` (`read_mask_hdf5` via `rust-hdf5`, `ndims()==2` guarded), `read_msk` (`decode_mask_msk`, fit2d bit-unpack, byte-verified vs fabio) read a 2D `uint8` mask and apply it through the shared `apply_loaded_mask` owner (crop/pad to the image, committed to undo). Dataset selection: `mask_datasets` enumerates + `load_h5_dataset` loads a named dataset; the "Select a 2D dataset" picker is ported (`begin_h5_load` → `show_toolbar` egui modal listing the datasets → `load_h5_dataset`). The file dialog is wired (`load_mask_dialog`, native rfd, npy/edf/tiff/hdf5/msk, extension-dispatched) behind the `show_toolbar` Load... button (silx `loadAction`). GPU-verified by `tests/mask_h5_picker.rs`. |
| ✅ | H | M | Scatter mask disk/circle drawing | `ScatterMaskToolsWidget.py:137-148 (updateDisk), 585-612 (pencil event handling)` | `ScatterMaskWidget::update_disk` masks points inside a disk: stencil `(y-cy)²+(x-cx)² < radius²` (silx `updateDisk`). Tested. |
| ✅ | H | M | Scatter mask polygon selection | `ScatterMaskToolsWidget.py:107-122 (updatePolygon with point-in-polygon test)` | `ScatterMaskWidget` masks points via `update_stencil` over a `point_in_polygon` test of each point (silx `Polygon.is_inside`). Tested. |
| ✅ | H | S | Mask level spinbox (1-255) with tooltip | `_BaseMaskToolsWidget.py:583-592` | `MaskToolsWidget::show_toolbar` renders a `Slider` over `level` 1..=255 (silx `levelSpinBox`; siplot uses a slider rather than a spinbox); the selected level is the highlighted/edited one. |
| ✅ | M | M | Mask visibility overlay with colormap | `MaskToolsWidget.py:371-392 (_updatePlotMask creates MaskImageData item), _BaseMaskToolsWidget.py:984-1010 (_setMaskColors)` | `colormap.rs` `mask_overlay_lut` builds the per-level discrete LUT (selected level full alpha, others half, level 0 transparent — faithful `_setMaskColors`); `mask_tools.rs` `apply()` uploads it as an overlay image at active-image z+1. Tested. GPU-unverified on-screen. |
| ✅ | M | M | Mask display sync on active item change | `MaskToolsWidget.py:402-433 (showEvent/hideEvent), 434-443 (_activeImageChanged)` | `ImageView::set_image` auto-calls `mask.reset_geometry(width,height)` on a dimension change before re-upload — the silx `ImageView`+mask parity target syncs automatically. (The standalone low-level `MaskToolsWidget` still needs a manual `reset_geometry`.) |
| ✅ | M | S | Drawing tool: Pencil/Brush with variable width | `_BaseMaskToolsWidget.py:822-846 (pencilSpinBox 1-1024, slider 1-50), MaskToolsWidget.py:854-876` | `7b6f263`: a 1-50 slider AND a 1-1024 `DragValue` spin box, both binding the same `brush_size` (egui keeps them in lockstep, ≈ silx `_pencilWidthChanged`), in both mask-draw toolbars. GPU-unverified on-screen. |
| ✅ Done | M | S | Drawing tool: Eraser (unmask) | `_BaseMaskToolsWidget.py:806-810 (maskStateGroup radio buttons Mask/Unmask, Ctrl modifier toggles)` | `d85c751`: Mask/Unmask radio (`mask_state`) plus `effective_do_mask = base ^ ctrl` (silx `_isMasking()`); holding Ctrl (egui `command`, = Cmd on macOS per Qt) inverts every draw, captured once per pencil stroke. siplot's mask stays boolean (no 1-255 levels). Toolbar GPU-unverified |
| ✅ | M | S | Clear mask (per level or all) | `_BaseMaskToolsWidget.py:198-205 (clear method), 655-670 (Clear/Clear All actions)` | `mask_tools.rs` `clear()` (current level only, silx `clear(level)`) + `clear_all()` (all levels), surfaced as "Clear" + "Clear All" buttons in `MaskToolsWidget::show_toolbar`. (ImageView's mask toolbar exposes `clear_all` only.) |
| ✅ Done | M | S | Invert mask (per level) | `_BaseMaskToolsWidget.py:207-218, 645-653 (invert action and handler)` | `ImageMask::invert` swaps masked/unmasked pixels (boolean mask, so a single "level"); `d85c751` adds the silx Ctrl+I shortcut via `consume_key(COMMAND, Key::I)` alongside the existing invert button. `invert_swaps_zero_and_current_level_only` tested. Toolbar GPU-unverified |
| ✅ | M | S | Mask NaN/non-finite values | `_BaseMaskToolsWidget.py:296-304 (updateNotFinite), 941-952 (button)` | `mask_tools.rs` `mask_not_finite` (`!v.is_finite()` stencil, silx `updateNotFinite`); "mask non-finite" button in ImageView's and ScatterView's mask toolbars. Tested. (Standalone `MaskToolsWidget::show_toolbar` does not carry this button.) |
| ✅ | M | S | Mask transparency/alpha slider | `_BaseMaskToolsWidget.py:554-577 (slider range 3-10, affects alpha in _setMaskColors)` | `badfbee`/`f3cbd95`: transparency slider in `MaskToolsWidget::show_toolbar` routes through the tested `set_transparency` (silx `transparencySlider` → `_setMaskColors`: selected level at this alpha, others at half) and re-renders the overlay LUT. (Same as row 202.) GPU-unverified on-screen. |
| ✅ | M | S | Mask interaction mode vs pan/zoom | `_BaseMaskToolsWidget.py:1115-1161 (mode activation sets plot.setInteractiveMode()), 1096-1106 (interaction mode changed handler)` | `ImageView::set_mask_draw(true)` switches the image plot to `PlotInteractionMode::MaskDraw` + `MaskTool::Pencil` (and restores Zoom/None on exit), so activating the mask tool disables pan/zoom automatically — `actions::mode::mask_draw_mode`. (The low-level standalone widget still relies on the caller setting the mode.) |
| ✅ Done | M | S | Mask/Unmask mode toggle with Ctrl modifier | `_BaseMaskToolsWidget.py:790-810 (radio buttons), 1169-1178 (_isMasking checks Ctrl)` | `d85c751`: `mask_state` Mask/Unmask radio + Ctrl-modifier inversion (`effective_do_mask = base ^ ctrl`, silx `_isMasking()`). Truth-table tested; toolbar GPU-unverified |
| ✅ | M | S | Load colormap range button | `_BaseMaskToolsWidget.py:883-892, 880-896 (MaskToolsWidget override)` | `0d83368`: "Min-max from colormap" button in ImageView's threshold row copies `colormap.vmin/vmax` (the effective post-autoscale range) into `mask.threshold_min/max`. Threshold masking itself (`update_below/between/above`) was already present. GPU-unverified on-screen. |
| ✅ | M | S | Pencil line interpolation between drag events | `MaskToolsWidget.py:849-876 (updateLine if lastPencilPos != current)` | `mask_tools.rs` `paint_pencil_point` tracks `last_pencil_pos` and calls `update_line` (Bresenham, width = brush size) from the previous sample before stamping the disk, so a fast drag leaves no gap (silx `updateLine`). Tested. |
| ✅ | L | S | Custom mask color per level (per-level RGB) | `_BaseMaskToolsWidget.py:394-398 (_defaultColors, _overlayColors arrays), 973-982 (getCurrentMaskColor), 1026-1046 (setMaskColors, getMaskColors)` | `overrides: Vec<Option<[u8;3]>>` + `set_mask_colors`/`reset_mask_colors` (faithful `setMaskColors`/`resetMaskColors`, per-level or all) flow through `mask_overlay_lut`; `current_mask_color` mirrors `getCurrentMaskColor`. `MaskToolsWidget::show_toolbar` exposes a per-level color swatch + "Reset color" button. Helpers tested; toolbar swatch GPU-unverified. |
| ✅ | L | S | Pan/Browse tool (no drawing) | `_BaseMaskToolsWidget.py:720-722 (PanModeAction added)` | `MaskTool::None` ("○", tooltip "Disable masking") in `show_toolbar` is the explicit no-draw tool (the default), leaving pan/zoom active — silx's Browse/pan button. |
| ✅ | L | S | Pencil size sync (spinbox + slider) | `_BaseMaskToolsWidget.py:825-846, 1057-1069` | A 1-50 `Slider` AND a `DragValue` spin box both bind the same `brush_size` (egui keeps them in lockstep, silx `_pencilWidthChanged`), in `show_toolbar` and the ImageView mask toolbar. Same as row 1209. |
## Actions, Toolbars, Tool Buttons — 31✅ 0◐ 0☐
siplot ships a rich unified toolbar via `show_toolbar()` / `show_toolbar_with()`, with `ToolbarResponse` exposing every state change. Implemented actions: Reset Zoom, Zoom In/Out, Zoom Back (limits-history pop), Select/Pan/Zoom modes, X/Y axis autoscale toggles, X/Y log scale, X/Y invert, Major/Minor grid, Keep-aspect, Show-axis, Crosshair, Curve Style cycle, Save (PNG/PPM/SVG/TIFF + CSV), Copy (clipboard), Print, Median Filter (1D/2D), Pixel-Intensity Histogram, and the Data-Aggregation mode selector (None/Max/Mean/Min). Colormap and colorbar have dedicated widgets (`ColormapDialog`, `ColorBarWidget`); profile/symbol tool buttons exist (`ProfileToolButton`, `SymbolToolButton`); `LimitsWidget` exposes editable axis-limit fields. silx's separate Qt composite toolbars (Interactive/Output/Curve/Image) are delivered as one immediate-mode `show_toolbar` + standalone widgets — a UI-paradigm difference, not missing features. The `PanWithArrowKeysAction` enable/disable toggle is now wired (`Plot::set_pan_with_arrow_keys` + `actions::control::toggle_pan_with_arrow_keys`, default-on per silx). The `RulerToolButton` is a reusable checkable widget with a tested distance core, now wired end-to-end: `PlotWidget::set_ruler_active` enters a line-ROI draw and labels the drawn line with its measured length (`distance_text`) live (`tests/ruler.rs`). The zoom-axes menu (silx `ZoomEnabledAxesMenu`) is wired. The Curve Style action now mirrors silx's plot-wide `(lines,points)` boolean cycle (`PlotWidget::cycle_curve_style` → `set_default_plot_lines`/`set_default_plot_points`), replacing the earlier concrete-`LineStyle` divergence. No ◐ remain in this section. The OpenGL-vs-Matplotlib backend toggle is N/A (single wgpu backend).
| | P | E | Feature | silx | gap |
|---|---|---|---|---|---|
| ✅ | H | L | Save Action (PNG/SVG/data formats) | `actions/io.py:77-747` | `SaveTarget` (`Figure(SaveFormat)` = PNG/PPM/SVG/TIFF, `CurveCsv` = data) + `from_extension`/`from_path`; the `Save` toolbar button (`ToolbarIcon::Save`) calls `save_graph`/`save_graph_with_format`. The figure GPU readback is the untestable shim. |
| ✅ | H | M | Zoom Back (limits history pop) | `actions/control.py:101-122` | `actions/control.rs` `zoom_back` pops `Plot::limits_history` (unbounded stack, pushed before each zoom/pan); wired to the `ToolbarIcon::ZoomBack` button (silx `ZoomBackAction` / `getLimitsHistory().pop()`). |
| ✅ | H | M | X Axis Autoscale (separate toggle) | `actions/control.py:172-197` | `actions/control.rs` `toggle_x_autoscale` flips `Plot::x_autoscale` and re-fits (silx `XAxisAutoScaleAction`, checkable); surfaced via `ToolbarIcon::AutoscaleX` + `ToolbarResponse::autoscale_x_changed`. |
| ✅ | H | M | Y Axis Autoscale (separate toggle) | `actions/control.py:199-224` | `actions/control.rs` `toggle_y_autoscale` flips `Plot::y_autoscale` and re-fits (silx `YAxisAutoScaleAction`); surfaced via `ToolbarIcon::AutoscaleY` + `ToolbarResponse::autoscale_y_changed`. |
| ✅ | H | M | OutputToolBar composite (Copy/Save/Print) | `tools/toolbars.py:71-113` | The Copy (`rgba_to_clipboard_image`), Save (`save_graph`), and Print (`print_graph`) actions are all present as `ToolbarIcon::Copy`/`Save`/`Print` buttons in the unified `show_toolbar` (siplot uses one toolbar rather than silx's separate QToolBar composites). |
| ✅ | M | L | Pixel Intensities Histogram Action | `actions/histogram.py:1-180+` | `actions/analysis.rs` `pixel_intensity_histogram` (silx Histogramnd + nanmean/nanstd/nansum → `PixelHistogram`); `ImageView::show_pixel_histogram_toolbar` opens a detached tool window (`detached::show_detached`) with live bars + stats + editable bin DragValue. GPU-unverified on-screen. |
| ✅ | M | M | Curve Style Cycling (lines/lines+marks/marks) | `actions/control.py:317-350` | `actions/control.rs` `curve_style_cycle` → `PlotWidget::cycle_curve_style` now mirrors silx's plot-wide `(lines,points)` boolean cycle: `(F,F)→(T,F)`, otherwise `(T,F)→(T,T)→(F,T)→(T,F)` (pure `next_curve_style_state`, silx `CurveStyleAction._actionTriggered`). It applies the new defaults to every curve via `set_default_plot_lines`/`set_default_plot_points` (silx `setDefaultPlotLines`/`setDefaultPlotPoints`: linestyle `"-"`/`" "`, symbol `"o"`/`""` on all `getAllCurves` items, i.e. `PlotItemKind::Curve`). State lives in `PlotWidget::default_plot_lines`/`default_plot_points` (silx init `True`/`False`), exposed via `is_default_plot_lines`/`is_default_plot_points`; toolbar `ToolbarIcon::CurveStyle` wired. Per-curve lines/points toggles remain separately via `set_curve_lines_visible`/`set_curve_points_visible`. Transition tested headlessly; the per-curve GPU re-apply is GPU-unverified. |
| ✅ | M | M | Colorbar Toggle (ColorBarAction) | `actions/control.py:452-488` | `actions/control.rs` `image_colorbar_toggle`/`scatter_colorbar_toggle` flip `show_colorbar`; surfaced as a selectable "colorbar" toolbar control in ImageView/ScatterView. |
| ✅ | M | M | Zoom Mode Action (with optional axes menu) | `actions/mode.py:45-106, toolbars.py:50-51` | Zoom mode button (checkable) plus a "Zoom axes" menu in `show_toolbar` (silx `ZoomEnabledAxesMenu`): X-axis / Y-axis checkboxes (default on) drive `Plot::set_zoom_enabled_axes`/`zoom_x_enabled`/`zoom_y_enabled`. A box zoom is constrained via the pure `interaction::constrain_zoom_axes` (silx `Zoom._getAxesExtent`: a disabled axis keeps its current range), wired at the box-zoom commit (`plot_widget.rs`), `ToolbarResponse::zoom_axes_changed`. siplot box zoom is left-axis only, so there is no y2 entry (silx's third). Constraint + state core tested; menu GPU/UI-unverified. |
| ✅ | M | M | Copy to Clipboard Action | `actions/io.py:848-927` | `PlotWidget::copy_to_clipboard(size)` (`high_level.rs:6379`) renders the figure to RGBA and writes it via `arboard::Clipboard` (`io::rgba_to_clipboard_image`); toolbar `ToolbarIcon::Copy` button wired (`:5552`, "Copy figure to clipboard"). The off-screen GPU render shares save_graph's headless-unverified boundary; the copy + clipboard-write path is implemented. |
| ✅ | M | M | InteractiveModeToolBar composite | `tools/toolbars.py:37-69` | Select/Pan/Zoom mode actions (`actions/mode.rs` `select_mode`/`pan_mode`/`zoom_mode`) render as `ToolbarIcon::Select`/`Pan`/`Zoom` in the unified `show_toolbar` (`ToolbarResponse::interaction_mode_changed`); `show_toolbar_with` allows custom button sets. egui immediate-mode UI has no Qt composable-QToolBar paradigm. |
| ✅ | M | M | CurveToolBar composite | `tools/toolbars.py:179-227` | All constituents exist in the unified `show_toolbar`: Reset Zoom (`Home`), X/Y Autoscale (`AutoscaleX`/`AutoscaleY`), Grid (`Grid`/`MinorGrid`), Curve Style (`CurveStyle`), Crosshair (`Cursor`). The stale "missing Curve Style and per-axis Autoscale" no longer holds. |
| ✅ | M | M | LimitsToolBar (editable X/Y min/max fields) | `tools/LimitsToolBar.py:35-123` | `PlotWidget::show_limits_toolbar` (`high_level.rs`): a `Limits: X: [min][max] Y: [min][max]` row of `DragValue` fields that always reflect the effective limits (silx `limitsChanged` slot) and, on edit, apply via `set_graph_x_limits`/`set_graph_y_limits` ordered min ≤ max (silx `_xFloatEditChanged` swap, `ordered_limits`, tested). Y edits the primary axis. |
| ✅ | M | S | Zoom In (1.1x factor) | `actions/control.py:124-146` | `control::zoom_in` → `apply_zoom(plot, ZOOM_STEP)` with `ZOOM_STEP = 1.1` (silx `applyZoomToPlot` factor) about the view center; toolbar `ToolbarIcon::ZoomIn` button wired (`high_level.rs:5370`); tested (`control.rs:272`). |
| ✅ | M | S | Zoom Out (1/1.1x factor) | `actions/control.py:148-170` | `control::zoom_out` → `apply_zoom(plot, 1.0 / ZOOM_STEP)`; toolbar `ToolbarIcon::ZoomOut` button wired (`high_level.rs:5374`); tested (`control.rs:278`). |
| ✅ | L | L | Print Action | `actions/io.py:747-847` | `print_graph` (rasterize → temp PNG via `print_temp_png_path` → printer) wired to the `ToolbarIcon::Print` button (`ToolbarResponse::print`). The GPU readback + printer submit are native shims (as with Save's figure readback); the in-app print-*preview* dialog is tracked separately (Stats/Print section). |
| ✅ | L | L | Median Filter Actions (1D/2D) | `actions/medfilt.py:49-150+` | `actions/analysis.rs` `median_filter_1d` and `median_filter_2d` (silx `MedianFilter1DAction`/`MedianFilter2DAction`); the `ToolbarIcon::MedianFilter` button toggles it. |
| ✅ Done | L | L | ScatterVisualizationToolButton | `PlotToolButtons.py:480-549` | `4477bc2`: `ScatterView` toolbar ComboBox over `ScatterVisualization::ALL` (silx order) → `set_visualization`. Note: the SOLID/GRID/triangulation *renderers* remain unimplemented (rows 1080/1086); only POINTS renders today, so non-POINTS modes are selectable but render as points. Toolbar GPU-unverified |
| ✅ | L | M | Pan with Arrow Keys Toggle | `actions/control.py:603-625` | `Plot::pan_with_arrow_keys` flag (default `true`, silx `_panWithArrowKeys = True`) with `set_pan_with_arrow_keys` (silx `setPanWithArrowKeys`); `plot_widget.rs` gates the arrow-key handler on it (silx `if self._panWithArrowKeys`); `actions::control::toggle_pan_with_arrow_keys` is the checkable `PanWithArrowKeysAction`. Arrow-key panning itself via `interaction.rs` `apply_pan` + `PanDirection`. |
| ✅ | L | M | Show Axis Toggle | `actions/control.py:627-651` | `actions/control.rs` `show_axis_toggle` flips `Plot::axes_displayed` (silx `ShowAxisAction`/`setAxesDisplayed`); surfaced via `ToolbarIcon::ShowAxis` + `ToolbarResponse::show_axis_changed`. Hidden axes collapse the chrome gutters. |
| ✅ | L | M | Close Polygon Interaction Action | `actions/control.py:653-683` | `DrawState::validate` (silx `_validate`) closes the in-progress polygon at its committed vertices; wired in `apply_interaction` to the Enter key while a `RoiCreate(Polygon)` draw is active. `len > 2` gate matches the snap-close path; no-op otherwise. `validate` unit-tested; key wiring GPU-unverified. |
| ✅ | L | M | Data Aggregation Mode Selector | `actions/image.py:45-104` | `ImageView::set_aggregation` + a `selectable_value` menu (None/Max/Mean/Min, `high_level.rs:8029-8032`) drive `AggregationMode` + GPU `aggregate_blocks` (silx `AggregationModeAction` / `ImageDataAggregated`). |
| ✅ | L | M | ProfileToolButton (1D/2D profiles) | `PlotToolButtons.py:304-392` | `widget::tool_buttons::ProfileToolButton` — a standalone dropdown for the 1D/2D dimension toggle (silx STATE labels/tooltips, `set_dimension`/`dimension`, `ui() -> Option<u8>`). Complements the existing None/H/V/L/R profile-mode selector. State core tested; popup GPU/UI-unverified. |
| ✅ | L | M | SymbolToolButton (marker/size) | `PlotToolButtons.py:458-478` | `widget::tool_buttons::SymbolToolButton` — a standalone dropdown over `Symbol::ALL` with a 1..=20 size slider (default 6.0), emitting `SymbolToolChange`. State core (symbol + clamped size) tested; popup GPU/UI-unverified. |
| ✅ | L | M | RulerToolButton (distance measurement tool) | `tools/RulerToolButton.py:83-180+` | `widget::tool_buttons::RulerToolButton` — a checkable button (silx `setCheckable`/`isChecked`/`toggled`) plus the pure distance formatter `distance_text` (silx `buildDistanceText`: Euclidean norm of the two data endpoints, formatted `" .1f"` + `px`), tested headlessly. The host integration is now wired end-to-end on `PlotWidget`: `set_ruler_active(true)` enters a line-ROI draw (`RoiCreate(Line)`, saving the prior mode), and `show` labels the drawn line ROI with its measured length via `distance_text`, recomputed live; a new measurement replaces the previous ruler line (silx single-measurement `_RulerROI`), and disarming removes the line + restores the prior mode. Verified by drag-simulation (`tests/ruler.rs`, 4 tests: arm enters the draw, a drag draws a line whose name equals its own length, a second drag replaces it, disarm removes it + restores the mode). |
| ✅ | L | S | Grid Toggle (both/major modes) | `actions/control.py:284-315` | `Plot::grid` (`GraphGrid::None`/`Major`/`MajorAndMinor`) toggled via `ToolbarIcon::Grid` + `MinorGrid` (two buttons, `grid_changed`/`minor_grid_changed`), covering silx `GridAction`'s major-vs-both modes. |
| ✅ N/A | L | S | OpenGL Backend Toggle | `actions/control.py:685-753` | N/A — silx `OpenGLAction` switches between its Matplotlib and OpenGL backends; siplot has a single wgpu backend, so there is no backend to toggle (the gap note itself says "no backend selection UI needed"). |
| ✅ | L | S | ImageToolBar composite | `tools/toolbars.py:115-177` | Constituents present: Reset Zoom (`Home`), Colormap (`ColormapDialog`), Aspect (`Aspect`), X/Y axis origin (`InvertX`/`InvertY`), plus a colorbar (`ColorBarWidget`) — via the unified `show_toolbar` + standalone widgets rather than a Qt composite class. |
| ✅ | L | S | AspectToolButton (menu: keep/don't keep) | `PlotToolButtons.py:57-125` | Keep-aspect is a toggle button (`ToolbarIcon::Aspect` "1:1" + `ToolbarResponse::aspect_changed`) over `Plot`'s keep-aspect state — the functional equivalent of silx's two-action menu (toggle vs menu is a UI-shape difference). |
| ✅ | L | S | XAxisOriginToolButton (menu: invert/non-invert X) | `PlotToolButtons.py:193-199` | X-axis inversion is a toggle button (`ToolbarIcon::InvertX` + `ToolbarResponse::x_inverted_changed`) — the functional equivalent of silx's menu-style invert/non-invert button (toggle vs menu is a UI-shape difference). |
| ✅ Done | L | S | ProfileOptionToolButton (sum/mean) | `PlotToolButtons.py:227-302` | `340b80c`: the `ProfileWindow` toolbar row has a Mean/Sum ComboBox driving `ProfileMethod` (functional equivalent of silx's menu button; in-window rather than a discrete tool-button popup). Window UI GPU-unverified |
## Composite views (ImageView/ScatterView/StackView/CompareImages/ComplexImageView/ImageStack) — 34✅ 0◐ 0☐
siplot implements all six composite views. ImageView has side histograms, axis sync, the `RadarView` position-overview, profile-toolbar integration (embedded + popup `ProfileWindow`), an intensity-histogram API (`active_image_histogram`), and aggregation modes. ScatterView has value-coloured scatter, an X/Y/Data/Index position-info panel, the `ScatterMaskWidget` per-point mask tool, and line-profile extraction across the scatter (`line_profile`, silx `ScatterProfileToolBar`). StackView browses frames plus 3D volumes (perspective/axis selection, auto-transposition, per-dimension labels, and per-frame max/mean/min block aggregation). CompareImages offers A/B, vertical-line (`HalfHalf`) and horizontal-line (`SplitHorizontal`) separators, A−B subtract, red-blue-gray RGB composite modes (`RedBlueGray`/`RedBlueGrayNeg`), and origin/center/stretch/**auto** image alignment for differently-shaped A and B (`CompareAlignment`: zero-pad to a common max grid, bilinear-resample B to A's shape, or SIFT-register B onto A). The SIFT `AUTO` path is now wired (`core::sift_align` on the pure-Rust `lowe-sift` crate — Lowe SIFT keypoints + ratio matching + affine least squares + bilinear warp, mirroring silx `__createSiftData`→`LinearAlign`), so the prior "needs a heavy computer-vision dependency" gap is closed. ComplexImageView renders the full `ComplexMode` set with a mode toolbar and amplitude-range controls. ImageStack provides a frame browser with a selectable table, per-frame visibility, and a waiting overlay. CompareImages also has a coordinate/value status bar (`show_status_bar`, silx `CompareImagesStatusBar`). Remaining CompareImages SIFT residuals are the affine-transform read-out (`getTransformation`, row 1559) and the keypoint-visibility scatter overlay (row 1561), both built on the now-landed registration; plus the interactive UI residuals on ScatterView's scatter-profile and StackView's full 3D-profile toolbar (◐, extraction cores done). ImageStack now also loads lazily: `set_sources`/`set_loader` load each slot on a background thread through the pluggable `FrameLoader` (silx `UrlLoader`/`setUrlLoaderClass`), `set_n_prefetch` preloads a radius of neighbours as you browse (silx `_preFetch`/`N_PRELOAD`), the concrete `Hdf5FrameLoader` reads 2D datasets and 3D-stack slices on demand (frozen-inventory row), and the URL table shows each source with a per-row remove (silx `UrlList`/`removeUrl`). The Composite-views section is now fully closed (34✅ 0◐ 0☐).
| | P | E | Feature | silx | gap |
|---|---|---|---|---|---|
| ✅ | M | L | ImageView: colorbar display | `ImageView.py:501` | `ImageView::colorbar()` (silx `getColorBarWidget`) builds a side `ColorBarWidget` from the active colormap; `ImageView::show` renders it (`high_level.rs:8295`) at the image's right (silx grid (0,2)). Visibility via `set_show_colorbar`; tested (`:11467`). |
| ✅ | M | L | ScatterView: colorbar widget | `ScatterView.py:83-88` | `ScatterView::colorbar() -> Option<ColorBarWidget>` (silx `getColorBarWidget`, `None` when no value colormap); `ScatterView::show` renders it to the right (`high_level.rs:9588`). Tracks the scatter value limits; tested (`:11484`). |
| ✅ | M | L | StackView: perspective selection (axis selection for 3D browsing) | `StackView.py:364-397` | `StackPerspective` + `perspective_ui` combo browses dimension 0, 1, or 2; `set_volume`+`stack_frame` auto-transpose per perspective |
| ✅ | M | M | ImageView: show/hide side histograms | `ImageView.py:552-559` | `is_side_histogram_displayed()` / `set_side_histogram_displayed(show)` (silx `isSideHistogramDisplayed`/`setSideHistogramDisplayed`); when hidden, `ImageView::show` (`high_level.rs:8228`) reserves no top/right strip or radar and the image reclaims that space. |
| ✅ | M | M | ImageView: valueChanged signal (pixel/histogram hover) | `ImageView.py:381-390,585-646` | `ImageView::value_changed() -> Option<(f64,f64,f64)>` (silx `valueChanged` `(col,row,value)`), computed by `image_value_at` from `self.cursor` (updated each frame in `show` at `high_level.rs:8305`); `None` off-image / before any pointer move (silx emits nothing then). Tested (`:10759`). |
| ✅ | M | M | ScatterView: position info panel (X, Y, Data, Index) | `ScatterView.py:90-101` | `show_position_info` shows X/Y/Data/Index, snapping X/Y/value/index to the picked scatter point (pixel-space pick, "-" when none) |
| ✅ | M | M | StackView: 3D transposition and dimension swapping | `StackView.py:409-441` | `stack_frame` re-slices the volume per perspective (silx transpose (1,0,2)/(2,0,1)); `set_perspective` rebuilds frames + axis labels |
| ✅ | M | M | CompareImages: vertical line separator (vline mode) | `CompareImages.py:124-133,397-407` | `CompareMode::HalfHalf` (silx `VisualizationMode.VERTICAL_LINE`): a left/right split at the `split` fraction — A left of the separator, B right. The split is set by an on-plot draggable `Marker::vline` (silx `__vline`) at `split * width`, dragging it folds back into `split` (silx `__separatorMoved`), or by the slider. `tests/compare_separator.rs`. |
| ✅ | M | M | CompareImages: horizontal line separator (hline mode) | `CompareImages.py:135-144,397-407` | `CompareMode::SplitHorizontal` (silx `VisualizationMode.HORIZONTAL_LINE`): a top/bottom split at the `split` fraction — A above the separator, B below. The split is set by an on-plot draggable `Marker::hline` (silx `__hline`) at `split * height`, or by the slider. `tests/compare_separator.rs`. |
| ✅ | L | L | ImageView: RadarView (position overview widget) | `ImageView.py:486-490,494-500` | `RadarView` mini-map: ImageView wires `set_data_bounds`/`set_viewport_limits` and renders the draggable viewport rect via `RadarView::ui`, dragging back through `set_limits` (silx full-range + current-viewport overview). |
| ✅ | L | L | ImageView: profile toolbar integration | `ImageView.py:451-453,692-697` | `show_profile_toolbar` (`ProfileMode` None/Horizontal/Vertical/Line/Rectangle) drives interactive profile extraction (`horizontal/vertical/line/rect_profile_values`), with profile curves added via `add_horizontal/vertical_profile_curve` (silx `ProfileToolBar`). |
| ✅ | L | L | ScatterView: mask tools widget (ScatterMaskToolsWidget) | `ScatterView.py:116-122` | `ScatterMaskWidget` (the silx `ScatterMaskToolsWidget` equivalent): a 1D per-point u8 mask edited by disk stencil (`update_disk`) or polygon (`update_stencil` + `point_in_polygon`) over scatter data. |
| ✅ | L | L | ScatterView: scatter profile toolbar | `ScatterView.py:141,306` | Extraction wired: `core::scatter_viz::scatter_line_profile` + `ScatterView::line_profile(start, end, n_points)` port silx `ScatterProfileToolBar::_computeProfile` (`tools/profile/rois.py:737-762`) — `numpy.linspace` samples interpolated through the scatter's Delaunay mesh (`LinearNDInterpolator` via `delaunay` + `interpolate`), `None` outside the convex hull (silx `NaN`), returning a `ScatterLineProfile`. Pure extraction headlessly tested (affine field exact along the line, outside-hull all-None, <3-points all-None). Side profile-plot **display now wired**: `ScatterView` owns a `ProfileWindow` (reserving plot id `id + 1`); `show_line_profile(start, end, n)` samples the profile, converts it to a value-vs-distance curve via the pure `ScatterLineProfile::distance_value_curve` (Euclidean distance from the first sample; out-of-hull `None` → `NaN` gaps), pushes it through the new `ProfileWindow::set_profile_curve`, and opens the side window — drawn each frame by `ScatterView::show`. Data path headlessly tested (`tests/scatter_profile.rs`: in-hull segment opens the window, out-of-hull / no-data are no-ops; `distance_value_curve` distances + NaN gaps); the window pixels render in their own egui viewport (GPU-unverified). Interactive **draw tool wired**: `set_profile_mode(true)` arms the line-profile tool — it switches the plot to `Select` (so a primary drag neither pans nor box-zooms, and with no ROI under the cursor nothing else consumes it) and `handle_profile_drag` maps the drag's start/current pixels to data via the plot transform and samples `show_line_profile` (silx `__nPoints = 1024`) each dragged frame; disarming restores `Zoom` and closes the window (silx deselect). End-to-end verified by a headless egui_kittest **drag simulation** (`tests/scatter_profile.rs`: a simulated drag across the hull opens the profile window; disarming closes it). |
| ✅ | L | L | StackView: profile 3D toolbar (extract profiles over all frames) | `StackView.py:84,948-951` | Full tool wired over the row-253 extraction cores. `show_profile3d_toolbar` draws silx `Profile3DToolBar`'s h-line/v-line/line ROI buttons plus a 1D/2D `profileType` toggle; a primary drag (`handle_profile_drag` → `show_profile`) routes to the 1D current-frame `ProfileWindow` curve (silx `"1D"`) or the new `StackProfileWindow` 2D image of the stacked profile (silx `"2D"`, `core.ImageProfileData` colormapped with the stack's own colormap). Drag-simulation verified (`tests/stack_profile_window.rs`, 5 tests). 2D requires a loaded volume (`set_volume`); flat-frames mode has no stack source. |
| ✅ | L | L | StackView: calibration support (per-axis scale/origin) | `StackView.py:551,565-566` | `core::Calibration` {None, Linear{constant,slope}} ports the affine subset of `silx.math.calibration` (`get_slope`/`is_affine`/`__call__`); `StackView::set_calibrations([Calibration;3])`/`calibrations()`/`calibrations_axes()`/`image_z()` mirror `getCalibrations(order=array/axes)` + `_getImageOrigin/Scale/Z`. The displayed image add applies the calibrated origin (`calib(0)`) + scale (slope) via `add_image_with_geometry`. Pure axes-ordering + geometry + Z headlessly tested; GPU image placement unverified |
| ✅ | L | L | CompareImages: composite RGB modes (red-blue-gray channels) | `tools/compare/core.py:60-61` | `CompareMode::RedBlueGray`/`RedBlueGrayNeg` (silx `COMPOSITE_RED_BLUE_GRAY`/`_NEG`): pure `red_blue_gray_composite` normalises A and B through the shared colormap to 0..=255 and lays them out R=a, G=a/2+b/2, B=b (NEG inverts each channel), faithful to `__composeRgbImage` (CompareImages.py:744-751). Toolbar buttons + `build_composite` arms wired; channel layout headlessly tested. GPU render is the shared CompareImages shim. |
| ✅ | L | L | CompareImages: alignment modes (origin/center/stretch/auto) | `tools/compare/core.py:66-72` | `CompareAlignment` {Origin,Center,Stretch,Auto} (silx `AlignmentMode`): `set_images` now takes separate `(w,h)` shapes for A and B, and the pure `align_compare_images` places them on a common grid per mode — Origin/Center zero-pad to `max×max` via `margin_image` (top-left, or silx `size//2−shape//2` centering), Stretch keeps A and bilinearly resamples B to A's shape via `rescale_array` (silx `__rescaleArray`/`BilinearImage.c_funct`, corner-aligned, edge-clamped). `raw_pixel_data` remaps the cursor per mode (`compare_aligned_coords`, AUTO via the estimated affine). Toolbar alignment selector wired (orig/ctr/fit/auto). **AUTO now wired** (row 1548): SIFT registration warps B onto A's grid; on too few keypoints it falls back to ORIGIN (silx `__setDefaultAlignmentMode`). 8 headless align tests + the SIFT core tests + 2 kittest AUTO end-to-end (register-shift, featureless-fallback). GPU render is the shared CompareImages shim. |
| ✅ | L | L | CompareImages: SIFT keypoint detection and alignment | `CompareImages.py:45,350,517-518` | `core::sift_align` (`sift_auto_align`) ports silx `__createSiftData`→`LinearAlign` on the pure-Rust `lowe-sift` crate (Lowe SIFT detector/descriptor/ratio matcher + `estimate_affine_from_pairs` = silx `matching_correction`'s 6-param affine least squares): detect SIFT keypoints on both images (padded to the common `max×max` grid), match descriptors (Lowe ratio 0.8), fit the affine A→B, and bilinearly warp B onto A's frame (zero border). `decompose_affine` mirrors `__toAffineTransformation` (`rot=atan2(-b,a)`, `sx=sign(a)·√(a²+b²)`, `sy=sign(d)·√(c²+d²)`), kept in image (x,y) order rather than silx's OpenCL (y,x) kernel swap. Wired as `CompareAlignment::Auto` (row 1547). 10 headless core tests (normalize, pad, decompose identity/rot+scale, warp identity/translation, recover-known-translation, reject too-few-matches/bad-lengths) + 2 kittest. Keypoint scatter overlay + affine read-out are rows 1561/1559. |
| ✅ | L | L | ComplexImageView: complex-valued image display | `ComplexImageView.py:259-310` | `ComplexImageView` renders 2D complex data via `ComplexMode` — Absolute/SquareAmplitude/Phase/Real/Imaginary/Log10Amplitude scalar maps + AmplitudePhase/Log10AmplitudePhase HSV-RGBA composites (the full silx set). |
| ✅ | L | L | ImageStack: lazy frame loading from URLs | `ImageStack.py:148-200` | `ImageStack::set_sources` + `set_loader` (silx `setUrls`/`setUrlLoaderClass`) switch to lazy mode: each slot starts empty (the existing `None`-slot waiting overlay = silx `WaitingOverlay`) and its frame is loaded on a spawned background thread through the pluggable `FrameLoader` trait (silx `UrlLoader.run`, returning `None` on failure), the result drained back on the UI thread and filled in place (silx `_urlLoaded`, queued connection); a failed slot is terminal (not retried, matching silx storing the `None` in `_urlData`). The pure `LoadSchedule` (NotRequested/InFlight/Loaded/Failed) gates dispatch + dedups (4 unit tests); `tests/image_stack_lazy.rs` (3 kittest) covers in-flight-overlay→fill, terminal-failure-not-retried, browse-loads-the-new-slot. Prefetch radius is row 1573; the concrete HDF5 loader is frozen-inventory row 444. |
| ✅ | L | L | ImageStack: URL selection table and browser | `ImageStack.py:65-128,337-392` | The selectable frame table + browser (slider + per-row toggleable visibility) is implemented (silx `_ToggleableUrlSelectionTable` + `UrlList`, `image_stack.rs`). In lazy mode the rows show the source/URL text (silx `UrlList`) via `row_label`, and a per-row "✕" removes that source: `remove_source(index)` (silx `removeUrl`/`sigUrlRemoved`) is the single owner of slot removal — it drops the source, frame, visibility, and load-state lanes in lockstep (so `sources.len() == frames.len()` holds by construction) and keeps the displayed frame where possible (`FrameNav::remove`: a removal before the current shifts its index down, removing the current points at the next clamped frame, removing after is index-neutral). 6 pure removal tests (before/current/after/last/OOB + the schedule lane) + a kittest (rows show the URLs; removing the middle realigns the lanes and the URL disappears). |
| ✅ | L | L | ImageStack: prefetch queue for smooth browsing | `ImageStack.py:251-293` | `ImageStack::set_n_prefetch`/`n_prefetch` (silx `setNPrefetch`/`getNPrefetch`, default 10 = `N_PRELOAD`) set the prefetch radius. The pure `prefetch_candidates(current, len, n)` window (current first, then the next `n`, then the previous `n`, each clamped) mirrors silx `setCurrentUrl` loading the current url then `_preFetch(_getNNextUrls(n))` + `_preFetch(_getNPreviousUrls(n))`; `load_candidates` feeds it into the same `LoadSchedule`-gated background-dispatch path (row 1571), so neighbours preload on their own threads as you browse. 5 pure window tests (middle/both-ends-clamp/zero-radius/empty/radius>len) + a kittest asserting current±1 load at radius 1 without navigating while out-of-window slots stay untouched. |
| ✅ | L | M | ImageView: getHistogram() API | `ImageView.py:699-725` | `ImageView::active_image_histogram` returns the active image's intensity histogram (data + extent), plus `show_pixel_histogram`/`show_pixel_histogram_toolbar` and a toggleable side histogram (silx `getHistogram`). |
| ✅ | L | M | ImageView: profile window behavior (popup/embedded) | `ImageView.py:392-401,656-690` | Profiles render embedded inline via `show_profile_toolbar` (`ProfileMode` H/V/Line/Rectangle) and in a popup via the standalone `ProfileWindow` widget (silx embedded-vs-popup behavior). |
| ✅ | L | M | ImageView: aggregation mode action (for multi-band images) | `ImageView.py:434-438,529-548` | `ImageView::aggregation`/`set_aggregation` + a None/Max/Mean/Min `selectable_value` menu drive `AggregationMode` + GPU `aggregate_blocks` (silx `AggregationModeAction`). |
| ✅ | L | M | ScatterView: getSelectionMask() and setSelectionMask() | `ScatterView.py:412-418` | `selection_mask`/`set_selection_mask` give programmatic per-point selection (u8 levels); set is length-validated + history-committed |
| ✅ | L | M | StackView: frame number / dimension labels | `StackView.py:799-827` | `set_dimension_labels`/`dimension_labels` (silx setLabels/getLabels); custom labels for 3D dims rotate onto X/Y axes per perspective |
| ✅ | L | M | StackView: aggregation mode for multi-band frames | `StackView.py:301-305` | `StackView::aggregation`/`set_aggregation` + a None/Max/Mean/Min combo and per-axis `bx`/`by` block factors in `show_frame_controls` (silx `AggregationModeAction` → `_stackItem.setAggregationMode`); each displayed frame routes through an `ImageSpec` carrying the calibrated origin/scale + `aggregation`/`aggregation_block`, driving the GPU `aggregate_blocks` reducer. silx's `Aggregation` enum is exactly NONE/MAX/MEAN/MIN (the prior "mean/sum" note was stale — there is no SUM) and siplot matches it. Reuses the tested `aggregate_blocks` primitive; StackView needs a wgpu RenderState no test builds, so the wiring is GPU-unverified (parity with ImageView row 1291). |
| ✅ | L | M | CompareImages: affine transformation tracking | `CompareImages.py:880-889` | `CompareImages::transformation() -> Option<AffineTransformation>` (silx `getTransformation`) returns the decomposed `(tx,ty,sx,sy,rotation)` affine — but ONLY from the SIFT/AUTO path: it reads the cached `sift_auto_align` result, so it is `Some` exactly while `CompareAlignment::Auto` registration has succeeded and `None` for origin/center/stretch (silx sets `__transformation = None` in `__updateData`, reassigns only in `__createSiftData`). The status bar now shows the silx `Align: Translation+Scale+Rotation` summary (`align_summary`, atol-0.01 notable test) with the per-component px/deg breakdown on hover (`align_tooltip`); silx's `Translation x`-gated-on-`ty` slip is corrected to gate on `tx`. 2 headless summary/tooltip tests + kittest asserting the (+3,+2) recovery and `None` after fallback. Affine is plain least squares (silx `matching_correction`, no RANSAC — outlier-sensitive). |
| ✅ | L | M | ComplexImageView: amplitude range dialog (max / delta in log10) | `ComplexImageView.py:50-155` | `ComplexImageView::show_amplitude_range_controls` + `set_amplitude_range_info`/`amplitude_range_info` adjust the displayed `(max_amplitude, delta)` (silx `_AmplitudeRangeDialog`: displayed_max + log10 dlogs) for the Log10AmplitudePhase composite. |
| ✅ | L | S | CompareImages: keypoint visibility toggle | `CompareImages.py:346-359` | `CompareImages::set_keypoints_visible`/`keypoints_visible` (silx `setKeypointsVisible`/`getKeypointsVisible`, off by default) + `matched_keypoints()` (silx `__matching_keypoints`). `sync_keypoint_overlay` (silx `__updateKeyPoints`) draws the matched keypoints as a point scatter at their image-A common-grid coords on top of the composite, rebuilt with the composite and cleared when hidden / non-AUTO. Toolbar "kp" checkbox wired. silx colours keypoints by scale via a dedicated colormap; siplot uses one conspicuous colour (`Symbol::Cross`). kittest toggles the overlay on/off and asserts the matched-keypoint set; the scatter pixels render GPU-unverified. |
| ✅ | L | S | CompareImages: status bar with coordinate/value info | `CompareImages.py:191-193` | `CompareImages::show_status_bar` (silx `CompareImagesStatusBar`): the cursor data coordinate (tracked from the pointer event in `show`) plus the raw A / B pixel value under it via `CompareImages::raw_pixel_data` — which remaps the display coordinate to each raw image per the alignment mode (`compare_aligned_coords`) before the bounds-checked `compare_pixel_at` lookup (silx `getRawPixelData`) — formatted as silx `_formatData` `%f`. The silx affine-transform label is now wired (`Align: …` summary + per-component hover, shown only in the SIFT/AUTO path where `getTransformation` is `Some` — row 1576). Lookup + format core tested; status-bar render GPU/UI-unverified. |
| ✅ | L | S | ComplexImageView: complex display mode toolbar | `ComplexImageView.py:157-256` | `ComplexImageView::show_mode_toolbar`/`show_mode_controls` present a `ComplexMode` selector (silx `_ComplexDataToolButton` dropdown: amplitude/phase/real/imag/...). |
| ✅ | L | S | ImageStack: waiting/loading overlay | `ImageStack.py:43` | A centred "waiting" spinner overlay is shown when the current frame slot is empty/`None` or hidden (silx `WaitingOverlay`, `image_stack.rs` module doc :20-23). |
## Stats, Legends, Profile, Fit, Position-Info, Print, Selection Dialogs — 21✅ 0◐ 0☐
siplot implements a full stats engine (min/max/delta/mean/sum/center-of-mass/coord-min/coord-max under `All`/`OnLimits`/`for_curve_roi`/`for_image` scopes), the `StatsWidget` table UI, legend display with single-click row selection, eye-icon visibility toggles, and a right-click context menu (Wave 9), profile extraction helpers for horizontal/vertical/line/rect/cross ROI types (incl. profile-over-stack) with automatic window placement mirroring silx, a fit widget spanning linear/Gaussian/GaussianArea/Lorentzian/PseudoVoigt/multi-Gaussian models with fit-range UI and a results table (value ± error, reduced chi-square), a `PositionInfo` readout bar with custom converters, the `ItemsSelectionDialog`, and a limits dialog for axis control. Remaining gaps are narrow: the RadarView standalone-widget conflict (code has `RadarView::ui`, the live re-baseline still flags it — needs adjudication), and print preview (needs a printer/PDF path).
| | P | E | Feature | silx | gap |
|---|---|---|---|---|---|
| ✅ | H | M | Stats engine: min/max/mean computation | `stats/stats.py:783-814` | `Stats` (stats.rs) computes the full silx `DEFAULT_STATS` set with finite-value filtering: min, max, delta (`max - min`), mean, sum, center-of-mass (`StatCOM`), and coord-min/coord-max (first argmin/argmax mapped to data coords). For curve and image scalars. Tested (`for_curve`/`for_image` cases). |
| ✅ | M | L | Stats context framework (data masking, bounds clipping, ROI filtering) | `stats/stats.py:143-600` | Bounds clipping (`StatScope::{All, OnLimits{x_range,y_range}}` + `Stats::for_curve`/`for_image`) AND ROI filtering (`Stats::for_curve_roi(xs, ys, from, to)`, plus arbitrary-shape per-ROI stats via `roi_stats`/`image_roi_stats`) cover silx `_StatsContext` in full. Verified against source: `_StatsContext`'s mask is ONLY the `onlimits` viewport rectangle (`_ImageContext`/`_CurveContext.clipData`: `int((v-origin)/scale)` index clip) or the `~roi.contains(points)` per-pixel ROI mask — there is **no** separate per-pixel user-mask kwarg (the prior "mask kwarg" note misread `numpy.ma.array(data, mask=clip_mask)`, where `clip_mask` is exactly that onlimits/roi clip). siplot mirrors both: `OnLimits` reproduces the index-window clip and `roi.contains` the per-pixel ROI mask. |
| ✅ | M | L | StatsWidget UI (scrollable table of stat rows, per-item, update mode toggle) | `StatsWidget.py:200-700` | `StatsWidget::ui` (`stats_widget.rs:175`): `ScrollArea::both` table, one row per `(label, item)` input, columns min/coords-min/max/coords-max/COM/mean/sum/delta (silx `DEFAULT_STATS` order) with `format_stat`/`format_coord` formatters, an Auto/Manual update-mode toggle (silx `UpdateMode`) + Manual "Update" button, and a "Visible data only" toggle. Active-vs-all live binding is tracked separately (rows 246/247). |
| ✅ | M | L | Stats: ROI-scoped statistics (compute stats within a selected ROI) | `stats/stats.py:68-140; ROIStatsWidget.py` | `Stats::for_curve_roi(xs, ys, from, to)` computes the full stat set over a curve x-range ROI; `roi_stats` (`image_roi_stats`/`curve_roi_stats`) computes per-ROI stats for arbitrary ROI shapes (image + curve), surfaced by `RoiStatsWidget` (rows above). Tested. |
| ✅ Done | M | M | Profile: line width / averaging method (mean vs sum) | `tools/profile/rois.py: _DefaultImageProfileRoiMixIn line_width, method properties` | `340b80c`: `ProfileWindow` exposes a Width DragValue + Mean/Sum ComboBox feeding the `line_profile_band`/`aligned_profile_values`/`rect_profile_values` extractors (band of N px, mean or sum). No longer mean-only. Window UI GPU-unverified |
| ✅ | M | M | Fit: gaussian fit (amplitude, center, width, background) | `silx.math.fit.fittheories: GaussianArea, etc.` | Both paths present: analytical `GaussianEstimateFit` AND iterative least-squares (`core::fitting::IterativeFit`, Levenberg-Marquardt → `IterativeFitResult` with covariance/chi-square). FitWidget model selector (`fit_widget.rs:366`) offers `IterativeGaussian`/`GaussianArea`/`Lorentzian`/`PseudoVoigt` (`PeakModel`), result via `iterative_result()`. |
| ✅ | M | M | Stats: on-limits computation (mask data to current X/Y viewport range) | `stats/stats.py:216-300; StatsWidget with onLimits checkbox` | `StatsWidget::on_visible_data` / `set_on_visible_data` (silx `setStatsOnVisibleData`) + "Visible data only" toggle; when on, `recompute` builds `StatScope::OnLimits { x_range, y_range }` from the passed viewport and `Stats::for_curve`/`for_image` clip to it. Tested (`recompute_on_visible_data_clips`). |
| ✅ | M | S | Fit: fit range selection UI (xmin, xmax for fitting region) | `silx.gui.fit: FitWidget with xmin/xmax input fields` | FitWidget `show` (`fit_widget.rs:383-401`): a checkbox toggles whole-curve vs a restricted `[xmin, xmax]` window, two `DragValue`s edit the bounds (seeded from the data's finite x extent via `default_fit_range`). Only points inside the window are fitted (`in_range_points`, public `set_fit_range`). |
| ✅ | L | L | Profile: profile-over-stack (slice extraction across stack dimension) | `tools/profile/rois.py:1058-1165 (ProfileImageStack* classes)` | `high_level::StackProfile` + `stack_aligned_profile`/`stack_line_profile` (row 253): one 1D profile per frame stacked row-major into `(frame_count, profile_len)`, via the `stack_profile_with` core over `stack_frame`. Mirrors silx `ProfileImageStack{HorizontalLine,VerticalLine,Line}ROI`. Extraction headlessly tested; profile-window curve display GPU-unverified. |
| ✅ | L | L | RadarView: miniature overview of full data extent with draggable viewport rect | `tools/RadarView.py:139-300` | `RadarView` (radar_view.rs) ports silx `RadarView.py:139-360`: a thumbnail of the data extent (gray fill/white outline) with a draggable blue viewport rect; `RadarView::ui` paints it and emits the dragged limits. Fully bound in `ImageView` (data extent via `set_data_bounds`, viewport via `set_viewport_limits`, drag → `Plot2D::set_limits`). Geometry (mapping/clamp/hit-test) unit-tested; paint GPU-unverified. |
| ✅ | L | L | Print preview: send plot to printable page with movable/resizable rect | `PrintPreviewToolButton.py:24-120` | `PrintPreview` (`print_preview.rs`) ports silx's `PrintPreviewDialog`: a detachable page editor that places the plot snapshot on a printable page where it can be dragged and corner-resized (`resize_item_rect`, faithful to silx `_GraphicsResizeRectItem` keep-ratio/free), with Clear All / Remove / Zoom± / keep-aspect / Save Page. `add_image` seeds the silx default print geometry (`_getViewBox`: 0.1 offset, 0.9 width, keep-aspect). Deviation: the final device print needs a non-portable `QPrinter`; the no-dep analogue is **Save Page** → `compose_page` PNG (silx's own print-to-file path), and system-printer submission is the separate `PrintDialog` (silx `PrintAction`, row 470). 12 tests; render path exercised headlessly. |
| ✅ | L | M | Legend context menu (set active, map-to-Y-left/right, toggle points/lines, rename, remove) | `LegendSelector.py:697-845 (LegendListContextMenu)` | Wave 9: right-click on a legend row pops an `egui::Response::context_menu`. Curve rows get Set Active / Map to Y Left / Map to Y Right / checkable Points / checkable Lines / Rename / Remove; non-curve rows get a Set Active / Rename / Remove subset. Points/Lines toggle losslessly via a UI-only restore cache; rename via an `egui::Window`. Backend was already primitively complete. **GPU/UI-UNVERIFIED:** the menu render, right-click interaction, and rename window need a GPU `RenderState` no crate test constructs; only the pure style transforms are unit-tested. silx's copy-color / toggle-colormap actions are not ported (egui has no equivalent clipboard color affordance here). |
| ✅ | L | M | Fit: Lorentzian fit (amplitude, center, width, background) | `silx.math.fit.fittheories: Lorentzian` | `PeakModel::Lorentzian` (`lorentzian_model`/`estimate_lorentzian`) fits via the iterative LM solver; offered by the FitWidget model selector. Numerics tested in `core::fitting`. |
| ✅ | L | M | Fit: Pseudo-Voigt fit (Gaussian + Lorentzian blend) | `silx.math.fit.fittheories: PseudoVoigt` | `PeakModel::PseudoVoigt` (`pseudo_voigt_model`/`estimate_pseudo_voigt`) fits via the iterative LM solver; offered by the FitWidget model selector. Numerics tested in `core::fitting`. |
| ✅ | L | M | PositionInfo readout bar (X, Y, plus custom converters) | `tools/PositionInfo.py:64-250` | `PositionInfo` (position_info.rs) holds an ordered list of `(label, converter)` pairs (X/Y defaults, a polar Radius/Angle pair, custom numeric converters formatted `%.7g`); `PositionInfo::ui(ui, cursor)` renders the readout bar. Converter logic tested; bar render GPU-unverified. |
| ✅ | L | M | ItemsSelectionDialog: multi-select table of plot items filtered by kind | `ItemsSelectionDialog.py:40-200; actions/fit.py:237-241` | `ItemsSelectionDialog` (Wave 6B-1) groups items by kind with a `KindsSelector`-style filter; `set_available_kinds` (silx `setAvailableKinds`) restricts offered kinds and `SelectionMode::Single/Multi` (silx `setItemsSelectionMode`) toggles single vs multi selection. Reused by the fit tool as a single-select curve+histogram picker (`examples/high_level_fit_widget.rs`, silx `_initFit`). Pure logic unit-tested; UI GPU-unverified. |
| ✅ | L | S | Profile: cross profile (horizontal + vertical lines from point) | `tools/profile/rois.py:663-700; _ProfileCrossROI` | `ProfileWindow::update_profile` over a `Roi::Cross` shows both the horizontal and vertical full profiles through the cross center as two simultaneous legended curves in one window (`profiles_for_roi` → `Vec<ProfileCurve>`, `curve_handles: Vec`). Faithful to `ProfileImageCrossROI`. Extraction tested; window GPU-unverified. |
| ✅ | L | S | Fit widget UI: show fit results table (chi-squared, parameters, errors) | `silx.gui.fit.FitWidget: results table with parameter names, values, errors` | FitWidget `show` renders a per-parameter `value ± error` column (`format_param_value_error`, error = covariance-diagonal sigma via `IterativeFitResult::std_errors`) and a "Reduced chi-square" row (`format_reduced_chisq`). Matches silx's FitWidget results table (which shows value/sigma, not a full covariance matrix). Formatters tested; table render GPU-unverified. |
| ✅ | L | S | Stats: center-of-mass (weighted sum of positions) | `stats/stats.py:881-910` | `Stats::com` (`ComCoord`): `COM = sum(pos·val)/sum(val)`, undefined when the finite-value sum is zero (silx NaN). Curve → `com[0]`; image → `(x, y)`. Tested. |
| ✅ | L | S | Stats: coordinate of min/max (argmin/argmax with axis lookup) | `stats/stats.py:841-878` | `Stats::coord_min`/`coord_max`: data coords of the first min/max (silx `StatCoordMin`/`StatCoordMax`, argmin/argmax to first extremum); image coords mapped `origin + scale·index`. Tested. |
| ✅ | L | S | Stats: integral (sum of all values, optionally weighted by axis) | `stats/stats.py via silx.math.combo` | `Stats::sum` (sum/integral of finite values). Tested. |