1b8b55d3ee37d13a9d8533784a343ac1755b4153
17
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
1b8b55d3ee |
feat: a phosphor density is the trace, one grid per trace in its own colour
A user looked at the display and reported three things at once: "there is clearly a gain offset between latest acquisition and persistent image", "persistent instead of being brighter + opaque is just gray/white", and "last acquisition does not seem to be affected, it looks like persistent image + last acquisition (so for example last acquisition never fade in time mode)". What they add up to is that this was not a phosphor yet: it was a **density with a solid trace drawn over it**. Four changes, and the first is the one the others follow from: - **The current acquisition is deposited like every other, and no solid trace is drawn over the image.** `Renderer::draw_traces()` returns at once while a density is on: the trace ink *is* the image, so the newest acquisition is a sweep inside it and fades with it. Drawing the polyline as well put a solid, unfading stroke over its own faint ghost -- the reported "persistent image + last acquisition", where the last acquisition never faded in time mode. - **One grid per trace, in that trace's own colour.** `Plot::density_grid(trace)` is one screen-sized `f32` grid per trace, deposited from that trace and drawn in `Trace::color`, because a density that had one hue for the plot could not say *which* trace put the ink there -- two signals agreeing in a cell would be one blended pixel. The display's memory is therefore `traces * columns * rows * 4` bytes rather than one plot's worth (a trace with no series, an unbound slot, holds no grid), which is the one cost this change adds: four signals in one 800x480 row are four grids, 6 MB. - **The ceiling is the trace's own brightness, not the fade's steady state.** `full_scale` now defaults to **one sweep**: a cell with one acquisition's ink is drawn in its trace's colour at that colour's own alpha, and a signal crossing the same place every acquisition settles *at* the colour its trace would have drawn (the ceiling clamps) rather than a fraction of a brighter steady state -- the gain offset the user reported. `Style::density`'s near-white 0.85 tint is gone with it, because there is no one tint any more. A caller that wants the fade's ramp on screen instead still sets `full_scale` to the sum the fade settles at and accepts the offset; that is a legitimate choice, just not the one a phosphor makes. The reading is a **framebuffer** one, because neither `ink`/`peak` nor a tint can make a colour or a fade: `a_phosphor_density_is_the_trace_in_its_own_colour` deposits one sweep, checks the saturated cell is *exactly* the trace's colour (`full=224`, the trace's `0xE0` green, against `bg=25` -- the old monochrome tint read ~190 red), then fades it one time constant and checks the same pixel is dimmer (`faded=115`) and still the trace's hue -- which a stroke over the density could not do. It moves the trace and draws without depositing to prove no polyline is drawn (`moved` equals the no-density frame at the moved row), and reads a steady state against a quarter of it (`steady=224`, `quarter=138`), in the signal's colour throughout. `a_density_holds_one_grid_per_trace` reads two traces' grids apart (`267 472 cells each, ink 584/584`, in two rows). The grid's own cases are unchanged where they should be (`a_density_halves_per_acquisition_and_keeps_the_newest_brightest` `peak_steady=2.000`, `a_density_fades_by_its_time_constant` `exp(-dt/tau)`). Suite: **397 cases, 0 failed**, 4/4 ctest entries. |
||
|
|
6a6915e64e |
feat: a plot can carry a phosphor density, one image for every acquisition
`Plot::set_density()` gives a plot a **phosphor density**: a screen-sized intensity image behind its traces, into which each acquisition is deposited and which fades by a time constant or by a per-acquisition factor. It is the library's and not a host's because the library is the side that knows a trace's device geometry -- a host would be re-rasterising traces the renderer has already staged, at a second derivation of the same mapping. It is an **image and not a set of retained traces**, and that is the whole of its cost: one `f32` cell per device pixel of the axes rect (`DensityGrid`), a cell's value a *number of sweeps*, so a display of a thousand acquisitions costs what one costs and there is no depth to get wrong. Measured on a 640x360 viewport's axes: **573x313 = 179 349 cells, 717 396 bytes** of grid, and one byte per cell uploaded on the frame the grid moved. - `Plot::accumulate_density()` deposits the acquisition on screen: every visible trace's window is mapped to sample indices and gathered exactly as `Renderer::draw_traces()` maps it, then walked column by column -- so the deposit is bounded by the axes' columns and not by the samples behind them -- and a cell takes **1.0 once per trace per sweep**, however many of that trace's own samples crossed it. Counting the hits instead put a single dense sweep at the ceiling of the brightness scale and hid the sweeps behind it, and it made a cell mean "how much the beam moved here" rather than "how many acquisitions were here"; the dedupe is a stamp per grid *row* (one `u64`, keyed by trace and column) so two sweeps agreeing on a cell still count twice. Dwell is a different question the library already answers (`Style::intensity_grading`). - `decay_density(dt_s)` fades by the clock, `clear_density()` and switching the density off drop the image at once. `DensityConfig` carries `full_scale` and `gamma`, applied on the CPU when the grid changed: the value a host reads back through `ink()`/`peak()` and the picture it sees are one derivation, and a frame whose grid did not move skips the conversion and the upload entirely (`FrameStats::density_cells`/`density_upload_bytes`, the latter the *frame's* total over every row, which one row's assignment used to overwrite). - The renderer draws it as one attribute-less quad over the axes rect (`kDensityVS`/`kDensityFS`, `DensityProgram`), over the grid and the invalid-span bands and **under the traces**, inside the axes' clip: it reserves no layout space and nothing on it can be picked, exactly as `Plot::set_invalid_spans()` does. `Style::density` is its tint. - The two fades are the two questions a persistence display can be asked -- "still visible a second later" and "the N-th back is gone" -- so the choice is the caller's. Six cases, all in the suite: four plot-level ones (the grid's resolution and memory, the per-acquisition schedule **584 -> 876 -> 1022** ink with `peak_steady=2.000`, `exp(-dt/tau)` off the ink, and the deposit's own geometry including a NaN as a gap) and one on the real driver (`a_phosphor_density_is_drawn_under_the_traces`: `cells=179349 uploaded=179349` on the moved frame and `uploaded=0` on the identical one after it, with the trace's stroke keeping its own colour over the ink). The suite is **396 cases, 0 failed** and all four ctest entries pass. |
||
|
|
2f7cdccdde |
docs: name and link the project this library is built for
The README listed the four host adapters and never said what any of them was for. The GTK4 one exists for udpscope, whose URL is now here, and the library's interaction layer -- cursors, measurement marks, invalid-span shading -- was shaped by what that host needed. The source and test notes that say "in udpscope's case" have a pointer to it now. |
||
|
|
be1f31a04f |
feat: an open box zoom draws the frame the release will apply
The controller has always tracked its selection; nothing drew it, so a Ctrl or Shift drag framed a range with no feedback until the release. The preview is the area the release will change, not what the pointer swept: Ctrl frames the x range only, so a band across the plot is what it will zoom, where the sliver a straight horizontal drag sweeps is only the gesture. The plot carries the rect a host hands it -- it learns nothing about pointers -- and the renderer draws a low-alpha fill under a light border over everything else inside the axes. |
||
|
|
37e414bcc5 |
feat: grade a sparse window's polyline by slope, so phosphor does not switch off on zoom
Intensity grading was gated on the column reduction, which is gated on a density whose only job is killing the moire beat. Dwell has nothing to do with that, so narrowing a window past the threshold turned the whole phosphor model off and a discontinuity went from a third of a flat top's ink to all of it in one pixel of zoom. stage_trace_slopes() grades each segment by its own slope instead, with a measure that agrees with the dense path at the boundary, and reports itself as FrameStats::slope_traces -- disjoint from column_traces, so a host asserting graded_traces == column_traces reads the same as before. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> |
||
|
|
cc908efa36 |
feat(render): a reduced column can be graded by dwell, not drawn as one flat band
A pixel column reduced from a dense envelope is honest about where the signal went and silent about how long it was there: its min and max are identical whether its samples fill the span or pile at its two ends, so a ramp and a pulse are one band. Style::intensity_grading grades it instead -- bright where the signal sits, faint where it only passes through -- by asking the gather for a level finer than the pixel grid and reading the distribution from those sub-buckets. Off by default: a change of look, not a correction of one. The measure is dwell per row, each segment depositing one unit spread over the rows it spans; counting +1 per row instead grades a sine's fast middle as brightly as its rails, which is the pattern inverted. The accumulator is one column wide (a full w*h density image is 33 MB at 4K) with each span written as two delta writes, and normalisation is against the column's own peak -- a frame-wide scale fades the trace out exactly where it moves fastest. Alpha is quantised into tiers, one LineBatch and one draw call each, because the line pipeline's colour is a uniform and a per-segment alpha is inexpressible. The finer ask is floored at level 1 whenever the flat path would have used the pyramid: level_for() answers 0 -- raw samples -- as soon as a sub-column falls under one bucket, so an unfloored ask turns a moderately dense frame into an O(visible) gather (measured at 172 samples a pixel: raw=1154 lod=0 and 6.4 ms a frame against raw=710 lod=453 and 2.3 ms). Grading is a way of drawing a reduction and may not undo it. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> |
||
|
|
e81e84645f |
fix(render): a dense trace is drawn one column at a time, not as a polyline
An LOD envelope staged as a polyline moirés. level_for() sizes a bucket at one to two pixel columns and Envelope emits two points per bucket, so a column carries three or four near-vertical hairlines at as many sub-pixel phases; under src-alpha compositing its brightness is then a non-linear function of those phases, the pattern repeats at the beat between the bucket pitch and the pixel pitch, and a pan sweeps the beat so the banding crawls. draw_traces now reduces such a window onto the pixel grid: one exactly vertical segment per integer column of the axes, from the lowest to the highest screen y any gathered point put in it, flushed in framebuffer pixels with the snap on. One phase for the whole trace, so there is nothing left to beat -- and the geometry becomes O(axes.w) rather than O(buckets). Gated on the gathered density rather than on the level (the raw path beats the same way), and on Envelope alone. A hole stays a hole, disjoint neighbours are joined so a fast edge keeps it, and a span under the stroke is drawn as the stroke since the shader caps nothing. It is the one carve-out in u_snap's data/chrome rule: a column is a pixel column, and the y bounds it reports are untouched. Measured by the new test as the number of distinct coverage levels in the ink -- 4 against the polyline's 126, with 627 segments against 60 054. A count of partially covered pixels was tried first and separates nothing (577 against 652): a 1.5 px stroke's fringe always spills into the next column. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> |
||
|
|
c960decaea |
feat(plot): the readout marker snaps to the sample it quotes, and the value mode is a choice
The mark was placed at the *pointer's* x with the quoted sample's y, so with the nearest-sample readout it sat off the datum horizontally -- and further off the deeper the zoom. `CrosshairRow` now carries the data `x` the value belongs to and the marker is placed from it, so in `NearestSample` the mark lands on the sample the panel is quoting. `Style::crosshair_marker_px` (9) and `crosshair_marker_width` (1.5) replace the renderer's hard-coded 3 px arm and 1 px stroke: a snapped mark lands among a trace's own sample dots (`marker_px`, 3) and at that size would read as one more sample rather than as the answer to the question the hover asks. `CrosshairValue` makes the previous behaviour reachable rather than deleted. The two modes answer different questions -- `NearestSample` "which datum is under the pointer", `Interpolated` "what is the signal at this instant" -- and only the caller knows which it is asking, so the plot offers both with the sample as the default. `crosshair_interpolate()` is restored for it. `Plot::crosshair_point()` is the lookup with both coordinates, public because a host that draws instruments of its own needs the same snap and a second rule for it would drift from this one; `crosshair_value()` delegates to it. |
||
|
|
c028f48fa0 |
fix(plot): a crosshair readout quotes a sample, never a value between two
The readout's y was a linear interpolation between the two samples adjacent to the pointer, which answers "what is the signal at this instant" -- a number the series does not contain. On a source whose samples are already averaged or decimated at ingest that is a value nothing measured, and at a zoom past the sample spacing it is visibly not the datum under the pointer. `crosshair_span()`'s `t` becomes a selector rather than a blend factor: the nearer end wins, a tie reading the earlier sample (the bias the span already takes for a degenerate pair). `t` is measured on the samples' own x through `neighbours()`/`x_at()`, so "nearer" is exact on a non-uniform clock too, and the lookup still reads level 0, so the value is a stored sample and never an LOD bucket's aggregate. `crosshair_interpolate()` had no other caller and is gone. `CrosshairRow::marker` is placed from the same y, so the dot follows the reported sample: past the sample spacing it sits beside the polyline rather than on it, deliberately -- one y feeding both keeps the mark and the number from ever disagreeing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> |
||
|
|
0ee6e10c81 |
fix(render): a clamped ring front does not draw a segment across the panel
A ring-backed series answers a *clamped* stamp for the oldest slots of its window -- the ones the producer has already overwritten -- because the published window is pinned while both rings slide under it, and the alternative (a sentinel) collapsed the whole index mapping. The consequence for a polyline is that the stamps do not increase at the *front*: the first points carry the newest stamp and everything after them is older. Drawn as they come, that is a segment from the newest sample back to the oldest -- a line across the whole panel, which a saturated ring shows as a flicker. The gathered points are now reduced to their longest strictly increasing suffix before anything is staged, so the unreadable front is left out and the real samples are untouched. A forward pass cannot express this: the bad points are at the front and everything behind them is legitimately smaller, so "drop anything that does not advance" would delete the trace -- walking backwards keeps each point strictly older than the one kept after it and stops at the first unreadable slot. One pass over the visible points, which the staging then walks anyway. Test: a_clamped_front_does_not_draw_a_segment_across_the_panel -- a series whose first two samples carry the newest stamp at -1 while the rest walk 0..50 at +1, the shape a saturated ChannelBuffer produces. Flat at +1 except for those two, so any ink below the middle of the axes is the bad segment: with the filter there is none (0 below, 1032 above in the columns the trace covers), and without it the diagonal crosses the panel. |
||
|
|
faef19ba60 |
fix(figure): a following group keeps its whole window on the data
The offset of a following group was clamped to `retained` -- measured from the newest sample -- so a pan (or a zoom-in anchored near an edge) could put the window's *right* edge exactly on the oldest retained sample. That leaves the window's entire span to its left over data the scope never had: an empty margin as wide as the view. The bound is about the whole window, so it is `retained - span` -- but only while the window *can* sit on the data. When the members retain less than a window's worth (a ring still filling, or one just trimmed) nothing avoids a margin, and the older placement is the honest one and stays: right edge on the newest sample, the shortfall on the left. Test: a_following_group_keeps_its_whole_window_on_the_data -- asks to scroll ten spans past the oldest sample and asserts the window is still a window, with its left edge on the data. The existing `a_following_log_group_holds_decades` codified the old bound (`max == edge/10` after asking for a decade of pan with far less than that retained); its log-space subject is unchanged and the offset assertion is now the invariant above. |
||
|
|
7dbe4e7183 |
feat(render): mark the samples of a window sparse enough to see them
`Style::marker_below_samples` (50 by default, `0` for never) draws a `marker_px` dot on every *raw* sample when the visible window holds at most that many, in the trace's own colour. Zoomed in far enough to see individual samples, the polyline between them stops being the shape a reader is looking at -- a handful of points across a whole panel ARE the picture -- and a dot makes each one a place rather than a bend in a line. Level 0 only: above it the geometry is pyramid buckets, which are not points anyone can find. Test: a_sparse_window_marks_its_samples -- the same flat series at ten samples and at a hundred. Ten gives exactly ten dots, evenly spaced (their centres are checked against the panel's own step, so "they are the samples" is asserted rather than "something was painted"); a hundred is past the threshold and gives none. The dots *group* into runs of columns (a marker is `marker_px` wide), which is how the test reads one marker per sample -- counting columns read three. |
||
|
|
01e410bd74 |
style(grid): the minor grid is thinner, dashed and blended into the background
Subdivisions used to be drawn exactly like the labelled lines -- same width, same opacity -- so a plot dense enough to need them read as two competing sets of ticks. The minor class is now three things the style states explicitly: - grid_minor_width 1.0 -> 0.75, a sub-pixel logical width. At 1x that is expressed as reduced coverage through the anti-aliased ramp; on a HiDPI display the line is literally narrower. - grid_minor_dash_on/_off (new, 3/3): dashed by default, off == 0 draws it solid, the same convention as the crosshair's and a trace's dash. The dash is a fragment-shader discard, so no geometry is added -- the vertices are the line either way. - grid_minor carries alpha (0x1C2126 at 50%), and the line shader applies u_color.a, so it blends into the background instead of sitting on it. Together that is about a fifth of the ink a major line leaves along its length, which is what gives the eye one grid to read instead of two. The major class is untouched: it stays the structure. Tests: a new pixel test (the_minor_grid_is_thinner_dashed_and_dimmer_than_the_major) asserts a major column is inked top-to-bottom while a minor one alternates, and that the minor's ink sits closer to the background than the major's. Three existing tests counted the minor class as "most of eight rows" of its exact colour; two now take their floor from the style (minor_line_ink: width x alpha x duty), and the width test states the dash and alpha away because widths are its subject. 361 cases, 0 failed. |
||
|
|
f3fb500068 |
fix(render): map the visible range through the series' own x
Renderer::draw_traces picked the sample window with (x - x0()) / dx(), and dx() is documented as a *nominal* spacing -- for a series whose x is a streamed timestamp it is only the mean of the retained window. The window it computed therefore landed inside the visible range, and the two end segments, the ones that enter and leave the frame, were never emitted: the left and right margins of a zoomed-in view came out empty. A wide enough drift dropped the trace entirely (last <= first). It asks Series::neighbours() now -- a binary search over the real x, the same call the crosshair reads through, costing the same. A uniform series answers the same question through the same call, so it is one path. dx is still read for the samples-per-pixel density estimate, where a nominal spacing is exactly right. Test: a_sparse_window_between_samples_still_inks_both_margins -- a series whose dx() lies, with the window strictly between two samples, so nothing inside the window is sampled and only the crossing segments can ink it. With the grid walk reverted it stages nothing at all (visible=0, gathered=0, covered=0/24); with the fix it stages the three bracketing samples and covers 24/24 columns. |
||
|
|
38ef459897 |
feat(plot)!: harden pipeline, XY series, dash/markers, touch, packaging
Breaking: Series::gather emits x relative to x0() and takes a GatherMode; consumers reconstruct absolute x in f64 before mapping (series.hpp documents the contract). Bugs, each pinned by a fail-before regression test: - render: f32 staging quantised epoch-time x into 128 px columns; every data-space draw now stages to_space(x) - to_space(origin) with the origin folded into the offset uniform in f64 (epoch/zero-origin twins rasterise identically) - ticks: major test was ULP-fragile and blanked epoch axes; majors are an integer-index test and are never truncated by the minor cap - gather: LOD envelope emitted buckets straddling [begin,end); ceil/floor bucket edges + raw lead/trail spans - live follow: log x axes held a linear span and zoomed every frame; span and offset now live in axis space (plot + both figure link-group paths) - input: a zero-notch wheel disarmed auto_scale on every axis; dy==0 pans x, factor == 1 / inf refused in wheel and drag zoom - render: the single text flush painted legend names over the crosshair readout panel; two batches, under flushed before the panel - data: NaN dropped from min/max unless it seeded the fold; non-finite samples are now excluded from every aggregate and from cached bounds Robustness: viewport normalisation (no clamp UB), non-finite range refusal, pyramid level cap at 57 (no shift/div UB through public gather), rounded ring capacity documented + pre-sized, GL blend func and UNPACK_ALIGNMENT save/restore, Renderer::release_gl() teardown, u64 buffer epochs, annotation captions clamped, button-aware GLFW row capture, cached figure row rects (no per-event layout), ruler-pair measurement refused, span-nonfinite guards in axis_ends. Features: XY series (sorted x, binary-search neighbours/snap, LOD over sample index); StreamSeries::freeze(); ArraySeries::push(); per-trace dash and markers (scatter at width 0) with auto or literal stride; GatherMode Envelope/Nearest/Mean via bucket_sum/sumsq; Style::line_aa_px; per-glyph text colour (readout values in trace colour); wheel-dx x pan; arrow-key pan; Escape cancels a drag and restores captured ranges; modifier-masked box zoom (Shift both, Ctrl x, Alt y); double-click reset via PointerEvent::time_s; ImGui key repeat unified; pinch gestures on Qt and GTK; pixelDelta wheel path and DevicePixelRatioChange handling; GNUInstallDirs, SOVERSION, SameMinorVersion, generated plot/version.hpp, relocatable plot.pc, licence install, FILES_MATCHING, GitHub Actions CI (debug/coverage/asan). Suite 283 -> 350 cases, 0 failed; coverage 93.9% (3730/3971) over 24 files, floor 80/90 held; ASAN preset suite green; all four demos run headless (the GTK scripted-resize check needs a window manager and fails identically on the parent commit); readme_main.cpp committed and compiles with the README command; pkg-config and find_package consumption verified from a relocated install prefix. |
||
|
|
ed3a8ce645 |
licence: MIT LICENSE and SPDX identifiers in every first-party file
The repository had no licence at all, so every source was all-rights-reserved by default. Add the verbatim MIT text at LICENSE (Copyright (c) 2026 Martino Ferrari) and a two-line SPDX header to each of the 113 first-party sources -- 48 include/plot, 26 src, 10 adapters, 5 examples, 21 tests, 3 tools -- so the grant travels with each file rather than relying on the root document alone. Scripts keep their shebang on line 1; the SPDX lines follow it. Genuinely vendored code is untouched: third_party/stb_truetype.h carries no header of ours. src/render/embedded_mono_font.cpp is generated, so tools/fonts/gen_atlas.py emit() now writes the two lines into its header comment and the file was regenerated -- gen_atlas.py --check passes and the regeneration adds only those comment lines to the previously committed file. README's provenance section now states MIT and links LICENSE. No code, test or public API change. |
||
|
|
059227d080 | docs: README with build, embedding and verification entry points |