diff --git a/deps/plotter b/deps/plotter index 6a6915e..7382052 160000 --- a/deps/plotter +++ b/deps/plotter @@ -1 +1 @@ -Subproject commit 6a6915e64e9a9bd5e7b8c93cfa600334bf192b10 +Subproject commit 738205293eca6e77d6876dbd0ad2df1a42f26791 diff --git a/docs/claude/invariants-trigger-and-pinning.md b/docs/claude/invariants-trigger-and-pinning.md index 4723572..1c8d7bb 100644 --- a/docs/claude/invariants-trigger-and-pinning.md +++ b/docs/claude/invariants-trigger-and-pinning.md @@ -403,6 +403,17 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear * **The switch deposits what is already on screen.** `on && !density_was_on` sets the same pending flag, so a user who turns persistence on while an event is held sees it instead of waiting for a latch that `single` mode may never send. + * **The brightness ceiling is the fade's steady state, and the ramp is linear in sweeps.** The app + states `DensityConfig::full_scale` from the fade it configured -- `1/(1 - factor)` for the + acquisition fade, the library's default two sweeps for the time one, which has no fixed steady + state (it settles where the rate and the constant meet) -- and leaves `gamma` at 1.0. A ceiling + at *one* sweep is the defect a user reported as "the colour fading is not applied, it looks + white": a single sweep was already full ink, everything above it clamped to the same pixel, and + a display of one sweep and a display of five were the same white. It is also the one half that + the *grid's* own numbers cannot see -- `ink`/`peak` were right while the picture was flat -- so + the assertion is a **framebuffer** one: `a_phosphor_density_is_drawn_under_the_traces` measures a + cell at a quarter of the scale against one at the ceiling and against the background + (`empty=21 full=83 quarter=54`), which is the reading the eye asked for. * **It is the plot's, so a figure rebuild resets it** -- the grid lives on each `Plot`, and `rebuild_all()` builds new ones. That is the one state a structural change (a row added, a signal switched off, a trace recoloured) does not carry: it costs the accumulated image and nothing else, diff --git a/src/ui.cpp b/src/ui.cpp index bc77641..0f85ce4 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -3396,6 +3396,18 @@ void apply_density(App& app, DisplayMode display, bool live_display) { : plot::DensityDecay::PerAcquisition; cfg.time_constant_s = static_cast(tc.density_tau_s); cfg.per_acquisition = static_cast(tc.density_factor); + // The brightness ceiling: the ink this fade settles at, so the newest acquisition is a + // *fraction* of full ink and the display reaches white only where the acquisitions agree. For a + // per-acquisition fade that is the geometric sum `1/(1-f)` exactly. A time fade has no fixed + // one -- it settles where the trigger's rate and the constant meet, which is the point of it -- + // so it keeps the library's default of two sweeps, and a fast trigger then reads brighter than + // a slow one the way a phosphor's does. The gamma is left at the library's 1.0: the cell is a + // count of sweeps, so half as many sweeps must read as half the ink. (With the ceiling at one + // sweep -- the library's first default -- one sweep was already full white and the fade was + // invisible, which is exactly what a user reported seeing.) + cfg.full_scale = tc.density_decay == PersistDecay::Time + ? 2.0f + : static_cast(1.0 / std::max(1.0 - tc.density_factor, 0.05)); // The plot drops what it held when this turns the display off, so the step below only ever // speaks about an image that is on screen. p->set_density(cfg);