Skip to content

feat(display): close the 3D stacked-trace wedges with offscreen spectrum - #4779

Merged
ten9876 merged 7 commits into
mainfrom
prototype/dss-frustum-fill
Aug 6, 2026
Merged

feat(display): close the 3D stacked-trace wedges with offscreen spectrum#4779
ten9876 merged 7 commits into
mainfrom
prototype/dss-frustum-fill

Conversation

@ten9876

@ten9876 ten9876 commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Closes #4778

What this does

In 3D Stacked Trace mode every row covers the same frequency span while the
far rows narrow to kBackWidthFrac (0.60), so the surface leaves two empty
black triangles flanking it.

This widens the span each row covers instead of the geometry that places
it. The near rows then run off both edges of the plot and the existing
perspective narrowing walks them back in with depth, so the wedge closes from
the front.

The projection is deliberately untouched — a frequency still lands at
0.5 + (freq - 0.5) * depthScale — so the converging slant of a signal
through depth, the frequency ruler, and every marker stay exactly where they
were.
Only what a row covers changed, never where a frequency lands.

The extra spectrum was already plumbed: buildDssSupplementalCoverage()
calibrates the native FLEX waterfall tile (wider than the panadapter) into
per-row supplemental channels, and dss_mesh.vert already sampled them outside
an FFT row's captured frame. Only the geometry was clipping them away.

What to test

There's a new 3D Span slider under 3D Gain (Display → 3D VIEW).

  • 0 restores today's rendering exactly. Please confirm it does — that is the
    regression guard for anyone who doesn't want this.
  • 100 spends all the overhang the source provides.
  • The slider scales the available span, not the absolute maximum, so 100
    always means "everything the radio gives" and every step below it visibly
    narrows the surface. (An absolute mapping left most of the travel dead
    against a ~1.15x tile.)

Worth a careful look at the band-edge markers, the frequency ruler, and slice
passband shading
— those are what a geometry change like this breaks quietly.
A signal should sit at the same horizontal position it always did; only the
extra spectrum beyond the ruler ends is new.

Non-FLEX sources ship no overhang, so Kiwi, the fallback producer, and the CPU
image fallback render exactly as before at any slider setting.

AETHER_DSS_ROW_SPAN=1.0..1.667 overrides the slider and, unlike the slider,
can demand more span than the source can fill — that is what exercises the
coverage feather.

Expected rough edges

These are known and tracked in #4778, not regressions to file:

  • Pan or zoom and the overhang collapses until fresh rows arrive. The retained
    history ring stores no supplemental and both reprojection paths wipe it.
    Same in scrollback.
  • The deepest rows can show a ragged silhouette where per-row coverage runs
    out — only rows carrying supplemental extend.
  • The CPU image fallback (no RGBA16F) still draws the clipped trapezoid, so it
    now visibly diverges from the GPU mesh path.

Verification

Measured offscreen under Xvfb at a matched 0.2 MHz span against a 1.153x
overhang, driving the slider over the automation bridge. Surface left edge in
px, front to back:

slider left edge, front → back
0 540 → 460 → 380 → 299 → 219 → 144
50 475 → 386 → 297 → 216 → 130 → 44
100 410 → 319 → 226 → 137 → 41 → 0

Monotone, closing from the front, with the residual at the back exactly where
wedgeFreeDepth(1.153) = 0.33 predicts. Confirmed on a live FLEX-8600.

237/237 tests pass, including new coverage for the span-1.0 identity, front
overhang, monotone narrowing, wedgeFreeDepth/coverage agreement, and that
widening never moves an in-band signal. tools/check_shader_dialects.py passes
— the new varying and uniform compile in all four baked GLSL slices
(130/140/150/300es).

🤖 Generated with Claude Code

Every 3DSS row covers the SAME frequency span while the far rows narrow to
kBackWidthFrac, so the surface leaves two empty black triangles flanking it.

Widen the span each row covers instead of the geometry that places it. The
near rows then run off both edges of the plot and the existing perspective
narrowing walks them back in with depth, closing the wedge from the front.
At 1/kBackWidthFrac (1.667x, i.e. +33% per side) the deepest row lands
exactly on the plot edge and no wedge remains at any depth.

The projection is deliberately untouched -- a frequency still lands at
0.5 + (freq - 0.5) * depthScale -- so the converging slant of a signal
through depth, the frequency ruler, and every marker stay exactly where
they were. Only what a row COVERS changed, never where a frequency lands.

The data path already existed: buildDssSupplementalCoverage() calibrates the
native waterfall tile (wider than the panadapter) into per-row supplemental
channels, and dss_mesh.vert already sampled them outside an FFT row's
captured frame. Only the geometry was clipping them away.

A "3D Span" slider under 3D Gain scales how much of the AVAILABLE overhang
to spend, so 100 always means "everything the source gives" and 0 restores
the classic narrowing trapezoid. Scaling the available span rather than the
absolute maximum matters: against a ~1.15x tile an absolute reading clamps
everything above ~22% to the same picture, leaving most of the travel dead.
No overhang gives 1.0 at every setting -- a strict identity -- so Kiwi, the
fallback producer, and the CPU image fallback render exactly as before.

effectiveDbmAt() now returns coverage alongside dBm, folded into
vBoundaryFade so a widened column with nothing behind it fades toward
bgFill and discards instead of extruding a flat floor plateau to the edge.

Verified offscreen at a matched 0.2 MHz span against a 1.153x overhang.
Surface left edge, front to back, driving the slider over the bridge:
span 0 gives 540 -> 460 -> 380 -> 299 -> 219 -> 144 px (the classic wedge),
span 50 gives 475 -> 386 -> 297 -> 216 -> 130 -> 44, span 100 gives
410 -> 319 -> 226 -> 137 -> 41 -> 0 -- monotone, closing from the front,
with the residual at the back exactly where wedgeFreeDepth(1.153) = 0.33
predicts. Confirmed on a live FLEX-8600. 237/237 tests pass.

Known limits, tracked in #4778 rather than fixed here:
- The retained history ring stores no supplemental and both reprojection
  paths wipe it, so the overhang collapses after a pan/zoom and throughout
  scrollback until fresh rows arrive.
- Only rows carrying supplemental extend, so the deepest rows can show a
  ragged silhouette where coverage runs out.
- DssRenderer::rebuild() (CPU fallback) ignores supplemental entirely and
  still draws the clipped trapezoid.

AETHER_DSS_ROW_SPAN=1.0..1.667 overrides the slider for A/B, and unlike the
slider it can demand more span than the source can fill, which is what
exercises the coverage feather.

Closes #4778
@ten9876
ten9876 requested review from a team as code owners August 5, 2026 16:48
@ten9876

ten9876 commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator Author

Note for testers: 3D Span is per-pan, not global.

It follows Display3DGain exactly — settingsKey() on all four paths (load,
save, session restore, Reset to Defaults), a per-widget runtime value, and a
signal wired per applet in wirePanadapter(). setPanIndex() is called
immediately before loadSettings() at every construction site, so each pan
reads its own key. Pan 0 is unsuffixed for backward compat; pan N uses
Display3DSpan_N.

The AETHER_DSS_ROW_SPAN env override is global and outranks the slider on
every pan. If you set it, all pans will move together — that is the override
working, not the setting being global. Unset it to test per-pan behaviour.

@aethersdr-agent aethersdr-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice piece of work, and the framing is right: widening what a row covers rather than where a frequency lands is the change that keeps the ruler, the markers and the converging slant honest, and the span-1.0 identity test is the guard that matters. I checked the std140 layout by hand — 21 scalars + 3 explicit pad floats + bgFill = 28, kDssMeshUboFloats 92→96, shadow offsets moved in step, and the writer order matches both shader blocks. AETHER_DSS_ROW_SPAN=0-equivalent (slider 0) really is bit-exact identity: rowSpanFactor lands on exactly 1.0f, freqUnit == u, insideViewport == 1 at both endpoints, and vSideDistance reduces to the old expression. Settings go through AppSettings with the same save-per-edit shape as setDssGain, and the reset path's new syncDisplaySettings arguments line up with the declaration order.

Two things I'd like resolved before merge, and both are narrow. The rest is polish.

Would like fixed before merge

  • The eased rowSpanFactor stalls partway whenever repaints stop (idle/disconnected stream) — the ease has no self-drive. src/gui/SpectrumWidget.cpp:13915
  • Widening spreads the fixed mesh column count over a wider frequency range, so the visible surface is sampled at cols / span against a Nearest-filtered height texture — at minimum this wants acknowledging, since it contradicts the sampler's stated invariant. resources/shaders/dss_mesh.vert:102

Polish

  • AETHER_DSS_ROW_SPAN is re-read and re-parsed every frame in the render path; the file already has the cached-static idiom for this. src/gui/SpectrumWidget.cpp:12513
  • applySliceShadow's new freqUnit parameter is always handed vFrequency verbatim — the parameterization plus the local alias is churn with no behaviour behind it. resources/shaders/dss_mesh.frag:102

Non-blocking notes

  • dssRowSpanTarget() holds the branchy half of the feature (slider scaling, env clamp, non-finite bandwidth) and nothing covers it; the new tests only reach the DssRenderer statics. src/gui/SpectrumWidget.cpp:12550

The known rough edges you listed (overhang collapse on pan/zoom, ragged deep rows, CPU-fallback divergence) read as correctly scoped to #4778 to me — I'm not counting any of them against this PR.


🤖 aethersdr-agent · cost: $5.3766 · model: claude-opus-5

Comment on lines +13915 to +13924
{
const float target =
dssRowSpanTarget(dssTargetBandwidthMhz);
constexpr float kRowSpanAlpha = 0.12f;
m_dssRowSpanFactor += kRowSpanAlpha
* (target - m_dssRowSpanFactor);
if (std::abs(target - m_dssRowSpanFactor) < 0.002f) {
m_dssRowSpanFactor = target;
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ease has nothing driving it once it starts. setDssRowSpan() issues a single update(), and the only other repaint source in 3D is m_waterfallScrollTimer, which stopWaterfallScrollAnimation() kills as soon as !m_wfLive (SpectrumWidget.cpp:1968). So with a paused or disconnected stream, dragging the slider paints exactly one frame, advances m_dssRowSpanFactor by 12% of the delta, and then freezes there — the surface sits at an arbitrary intermediate span until data resumes. Same for a zoom that changes the available overhang while idle.

Cheapest fix is to keep the ease alive itself:

Suggested change
{
const float target =
dssRowSpanTarget(dssTargetBandwidthMhz);
constexpr float kRowSpanAlpha = 0.12f;
m_dssRowSpanFactor += kRowSpanAlpha
* (target - m_dssRowSpanFactor);
if (std::abs(target - m_dssRowSpanFactor) < 0.002f) {
m_dssRowSpanFactor = target;
}
}
{
const float target =
dssRowSpanTarget(dssTargetBandwidthMhz);
constexpr float kRowSpanAlpha = 0.12f;
m_dssRowSpanFactor += kRowSpanAlpha
* (target - m_dssRowSpanFactor);
if (std::abs(target - m_dssRowSpanFactor) < 0.002f) {
m_dssRowSpanFactor = target;
} else {
// Nothing else drives repaints while the stream is idle,
// so the ease has to keep itself alive or it freezes
// partway through a slider drag.
update();
}
}

Comment on lines +102 to +105
float dssFreqUnit(float meshU)
{
return 0.5 + (meshU - 0.5) * rowSpanFactor;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The mesh grid is built once with exactly m_dss.cols() columns across u ∈ [0,1] (SpectrumWidget.cpp initialize()), and cols is the FFT bin count — which is why the height sampler is Nearest, with the comment "the grid is as dense as the texture". dssFreqUnit spreads those same columns over rowSpanFactor × the frequency range, so the portion landing inside the viewport now carries cols / rowSpanFactor columns against an unchanged cols-texel texture. At the ~1.153x tile you measured that's ~13% of bins never sampled by any vertex; at the 1.667 cap it's 40%.

With Nearest filtering that isn't a smooth softening — individual bins drop out, and which ones drop shifts as the eased factor moves, so a narrow CW carrier can flicker or vanish at the very setting the feature ships at by default. Nothing in the PR body or the tooltip mentions it.

Not asking you to resample the mesh in this PR. But it's the one invariant this change quietly breaks, so it should either be stated in #4778 alongside the other rough edges, or (cheaper) the mesh column count could be scaled by kMaxRowSpanFactor at build time so the visible density never drops below today's.

Comment thread src/gui/SpectrumWidget.cpp Outdated
Comment thread src/gui/SpectrumWidget.cpp Outdated
Comment thread resources/shaders/dss_mesh.frag Outdated

@aethersdr-agent aethersdr-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice piece of work — widening what a row covers while leaving plotX = 0.5 + (freq - 0.5) * depthScale alone is the right decomposition, and pulling depthScale/rowFrequencyUnit into DssRenderer.h so the shader and the CPU-side geometry share one formula (plus the span-1.0 identity test and the "widening never moves an in-band signal" test) is exactly the guard this kind of change needs. The std140 padding is handled correctly and consistently across .vert/.frag/kDssMeshUboFloats/kShadowBandsOffset, the shadow-decal clamp widening to [-margin, 1+margin] matches dssFreqUnit's range exactly, and all three syncDisplaySettings() call sites were updated rather than leaning on the new default argument. Verification is unusually concrete.

Two things I'd like looked at before merge, both narrow.

Would like fixed before merge

  • AETHER_DSS_ROW_SPAN is applied unconditionally, but the vertex shader's out-of-row guard is gated on rowFrequencyFrames, so on a non-FLEX source the override samples past the height texture and ClampToEdge extrudes the edge bin instead of feathering.

Polish

  • The rowSpanFactor ease only advances on frames that happen to be drawn; with no data flowing it can settle mid-transition.

Non-blocking notes

  • vSideDistance's comment about off-screen ends doesn't match what the expression computes (the code is right, the comment isn't).
  • The default of 100 (and the reset-to-defaults value) means FLEX users get the new silhouette without opting in — a product call, just flagging it since the PR frames 0 as the reference rendering.

🤖 aethersdr-agent · cost: $2.2549 · model: claude-opus-5

Comment thread src/gui/SpectrumWidget.cpp Outdated
Comment on lines +13918 to +13922
constexpr float kRowSpanAlpha = 0.12f;
m_dssRowSpanFactor += kRowSpanAlpha
* (target - m_dssRowSpanFactor);
if (std::abs(target - m_dssRowSpanFactor) < 0.002f) {
m_dssRowSpanFactor = target;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ease only steps on frames that are actually drawn. setDssRowSpan() calls update() once, which advances the factor by 12% and schedules nothing further — so if the display isn't repainting for another reason (disconnected, paused, scrollback held still), the silhouette parks partway to the target and stays there until something else triggers a repaint.

While live data is flowing this is invisible, and dragging the slider produces enough updates to converge, but a single click on the groove or an arrow-key step on a quiet pan will visibly stall. Cheapest fix is to keep the frame pump going while it's unsettled — request another update from this block when std::abs(target - m_dssRowSpanFactor) >= 0.002f.

Comment on lines 250 to +254
float plotY = edge > 0.5 ? 1.0 : topY;
// Half the row's on-screen width, times how far this column sits from the
// nearer end. Off-screen ends give a large value, so the fade correctly
// leaves them alone.
vSideDistance = min(u, 1.0 - u) * rowSpanFactor * w;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment has it backwards: min(u, 1.0 - u) is 0 at the row ends, so an off-screen end gets the smallest value, not a large one. The behaviour is still correct — that fade band simply sits outside the plot and gets clipped — but as written the comment describes a mechanism that isn't there, and the next reader may "fix" the expression to match it.

Worth restating as: distance is measured from the row's own silhouette, so at span > 1 the faded strip falls outside the viewport and is clipped away; at span 1.0 this is bit-identical to the old min(vFrequency, 1 - vFrequency) * perspectiveWidth.

@aethersdr-agent aethersdr-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice piece of work, and the framing is the right one — widening what a row covers rather than how a frequency projects is what keeps the ruler, markers and the converging slant honest, and the span-1.0 identity holds exactly under algebra, not just under test (rowFrequencyUnit is u, and vSideDistance = min(u,1-u)*w reproduces the old min(vFrequency,1-vFrequency)*perspectiveWidth term term-for-term). The UBO change checks out too: 21 scalars + 3 explicit pad floats, both the .vert and .frag blocks agree, every downstream offset is derived from kShadowBandsOffset, and std::size(uboScalars) == kDssMeshScalarFields guards the writer.

The one thing I'd want addressed is a span-collapse trigger that isn't in your known-rough-edges list, and which operators will hit far more often than pan/zoom: transmit. Everything else is polish.

Would like fixed before merge

  • dssRowSpanTarget() reads age-0 supplemental only, so the surface collapses to the trapezoid and re-expands on every TX and on any native-tile stall.

Polish

  • AETHER_DSS_ROW_SPAN is re-read and re-parsed (with a QString allocation) on every rendered frame.
  • The 3D Span slider is live but inert on the CPU image fallback, with nothing in the UI saying so.

Non-blocking notes

  • I checked whether the new overhang could bleed onto the dBm strip: it can't. The DSS mesh viewport is specContentW wide at specRect.x(), so NDC clipping bounds the off-plot vertices to the plot area.
  • I also checked the ClampToEdge height sampler as a possible smear source for the widened columns. Not reachable — rowFrequencyFrames is written as a literal 1.0f in the mesh UBO, so outsideRow always evaluates and out-of-range columns take the covered = 0 feather. Kiwi and the fallback producer are safe even under the env override, which is a stronger guarantee than the PR description claims.
  • The applySliceShadow(..., freqUnit) re-parameterisation is a no-op — freqUnit is assigned straight from vFrequency and nothing else is ever passed. Correct as written (the fwidth derivative is unchanged either way), just a chunk of diff that doesn't need to be there.

🤖 aethersdr-agent · cost: $9.4363 · model: claude-opus-5

Comment thread src/gui/SpectrumWidget.cpp Outdated
Comment thread src/gui/SpectrumWidget.cpp Outdated
Comment thread src/gui/SpectrumOverlayMenu.cpp Outdated
@rfoust rfoust self-assigned this Aug 5, 2026
…l density

Two findings from review on #4779.

The rowSpanFactor ease advanced one step per PRESENTED frame with nothing
keeping frames coming, so it stalled wherever the last repaint left it any
time nothing else was driving the pane. On an idle or disconnected stream
the slider read 100 over a half-widened surface. It now requests its own
frame until it converges.

More seriously, widening spread a fixed kCols-wide mesh over rowSpanFactor x
the viewport while the height texture still holds kCols texels ACROSS the
viewport, leaving (span-1)/span of them unread. That is not shimmer: the
mesh-column-to-frequency mapping is static, so the same texels were missed
every frame and a narrow carrier landing on one was permanently invisible at
a fixed screen position -- a real regression for CW on the primary display,
and it silently falsified the height sampler's "the grid is as dense as the
texture" justification for Nearest filtering.

Size the mesh for the widest span instead (kMeshCols = 1280), so the
on-screen columns are never sparser than the texture at any span; below the
maximum it merely oversamples, which Nearest absorbs by repeating texels. A
static_assert pins the invariant so a future kBackWidthFrac change cannot
quietly reintroduce the blind columns, and the sampler comment now states
what actually holds. Static vertex storage goes from about 20.2 to 33.7 MiB,
which is the honest price of the feature.

meshCols joins the UBO in one of the std140 pad slots, so the block layout,
kDssMeshUboFloats and every shadow offset are unchanged. dss_mesh.vert's
ribbon tangent probe now steps one mesh column rather than one texel, which
are no longer the same thing.

Declined from the same review: the claim that AETHER_DSS_ROW_SPAN samples
past the height texture on non-FLEX sources. rowFrequencyFrames is the
hardcoded literal 1.0f at UBO index 11, not derived from the source, so the
out-of-row guard is active on every backend and the ClampToEdge extrusion
described cannot occur.

Full build green, 237/237 tests pass, shader dialects check clean, and the
offscreen slider sweep is unchanged in character.
@ten9876

ten9876 commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks both — pushed fb3e7ddf addressing the two real blockers, and declining one.

Declined

"AETHER_DSS_ROW_SPAN samples past the height texture on non-FLEX sources,
ClampToEdge extrudes the edge bin instead of feathering."

rowFrequencyFrames is the hardcoded literal 1.0f at UBO index 11 — it is not
derived from the source or the backend:

static_cast<float>(dssTargetBandwidthMhz),
0.0f, 1.0f,        // targetCenterOffsetMhz, rowFrequencyFrames

So outsideRow in effectiveDbmAt() is active on every backend and the
described extrusion can't occur. I think this was conflated with the unrelated
local of the same name in commitFrequencyPreview() (SpectrumWidget.cpp:6115),
which is about the waterfall pipeline mode. Happy to be shown otherwise.

Fixed: the ease could stall

Correct, and worse than cosmetic — setDssRowSpan() scheduled a single
update() while the ease needs ~30 frames at α=0.12, so on an idle or
disconnected stream the slider read 100 over a half-widened surface. It now
requests its own frame until it converges.

Fixed: the sampler invariant, properly

This was the sharpest catch in either review, and it was worse than "wants
acknowledging". A fixed kCols-wide mesh spread over span × the viewport
left (span-1)/span of the texture's texels unread — and because the
mesh-column-to-frequency mapping is static, the same texels were missed every
frame. Not shimmer: a narrow carrier landing on one was permanently invisible
at a fixed screen position
. On a receiver's primary display, with CW, that is
a real regression, and it silently falsified the Nearest justification.

So rather than document it, I sized the mesh for the widest span —
kMeshCols = 1280 — so the on-screen columns are never sparser than the texture
at any span. Below maximum it oversamples, which Nearest absorbs by repeating
texels. A static_assert pins the invariant so a future kBackWidthFrac change
can't quietly reintroduce the blind columns, and the sampler comment now states
what actually holds. Static vertex storage goes 20.2 → 33.7 MiB; that's the
honest price and it's disclosed at dssFillVerticesPerRow().

meshCols went into one of the existing std140 pad slots, so the block layout,
kDssMeshUboFloats and every shadow offset are unchanged. The ribbon tangent
probe now steps one mesh column rather than one texel, which are no longer the
same thing.

Not addressed in this push

The polish and non-blocking items are still open by choice, not oversight: the
per-frame env re-parse, the applySliceShadow freqUnit parameter churn, the
inaccurate vSideDistance comment, and test coverage for dssRowSpanTarget().
Say the word and I'll take them in a follow-up commit.

The default-100 question is a product call and is with @ten9876.

Full build green, 237/237 tests pass, check_shader_dialects.py clean, and the
offscreen slider sweep is unchanged in character after the mesh resize.

@rfoust rfoust left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking finding

[P2] Display3DSpan is introduced as a new loose AppSettings key in SpectrumWidget.cpp (load around line 2430; write around line 4540), with reset/restore handling in MainWindow_Wiring.cpp and MainWindow_Session.cpp. Constitution Principle V requires each new feature configuration to be one self-contained, owned, versioned object and explicitly says that legacy flat keys are grandfathered but nothing new may be added to them.

Please move the row-span setting into an owned 3D-display configuration object before merge. Migrating the adjacent legacy Display3DGain value into that object at the same time would give the feature one clear defaulting, migration, and atomic-persistence boundary.

Nonblocking cleanup

  • Give the new 3D Span slider an accessible name/description or a proper label buddy.
  • Add braces around the single-line label-update if in SpectrumOverlayMenu.cpp per project style.

The renderer geometry and data-flow changes otherwise look coherent. I reviewed the incremental update through fb3e7dd as well; it does not address the configuration blocker.

@rfoust rfoust removed their assignment Aug 5, 2026
ten9876 added 2 commits August 5, 2026 11:07
- Resolve AETHER_DSS_ROW_SPAN once instead of re-reading and re-parsing the
  environment on every frame from renderGpuFrame(). It cannot change under a
  running process.
- Drop applySliceShadow()'s freqUnit parameter. After the re-anchor it was
  always handed vFrequency verbatim, so the parameter plus the local alias
  was diff noise with no behaviour behind it.
- Correct the vSideDistance comment. min(u, 1-u) is ZERO at the row ends, not
  large, so the fade always applies there; on a widened row that strip simply
  falls outside the plot and is clipped. As written the comment described a
  mechanism that is not there, which invites "fixing" the expression to match.
- Extract the taper into DssRenderer::rowSpanFactorFor() and cover it. This
  was the branchy half of the feature -- percentage scaling, degenerate
  frequency frames, saturation -- and nothing reached it, since the existing
  tests only exercised the geometry statics.

The new tests are mutation-checked: reverting the mapping to scale the
ABSOLUTE maximum rather than the AVAILABLE span fails "50% must spend half
the available overhang", which is the dead-travel bug the mapping exists to
avoid.

Full build green, 237/237 tests pass, shader dialects clean.
Principle V: every feature's configuration is one self-contained object
under a single root key, and while the legacy flat keys are grandfathered,
nothing new may be added to them. Display3DSpan was doing exactly that.

Introduce Display3DSettings -- {"version":1,"gain":N,"span":N} -- as the 3D
view's owned object, mirroring the DisplaySourceTraceSettings pattern already
next to it. Display3DSpan was added by this branch and never shipped, so it
is gone rather than migrated. The legacy Display3DGain key is grandfathered:
it is read once to seed the object when none exists, then the object owns the
value. That gives the feature one place to default, one to migrate, and one
value to write.

Both setters now persist the whole object through a single
AppSettings::setValue + save() (Principle XIV), so a crash cannot leave the
3D view half-configured the way two independent per-key writes could. Reset
to Defaults goes through resetDisplay3DSettings(), which writes
unconditionally -- the setters early-return when a value already matches,
which would otherwise leave a stale object behind on a partial reset. Profile
recall re-applies the object as a unit instead of reading two flat keys.

Storage is AppSettings, which is sqlite-backed (app_settings table); QSettings
INI is only a one-time first-launch migration source and is never written.
Verified end to end: driving the slider persists
Display3DSettings={"gain":70,"span":0,"version":1} with no flat key and no
INI/XML file created, and seeding a legacy Display3DGain=42 with no object
present carries 42 into the object on next launch.

Also from the same review:
- Give the 3D Span slider an accessible name and description.
- Brace the single-line label update per project style.

3D Floor is deliberately not folded in: it is per-source and already owned by
DisplaySourceTraceSettings.

Full build green, 237/237 tests pass.
@ten9876

ten9876 commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator Author

@rfoust — blocker addressed in ec3f3080, plus both nonblocking items and the
earlier bot polish in 8e4c04de.

Principle V: Display3DSettings

You were right that Display3DSpan was a new loose flat key. It's gone —
introduced on this branch and never shipped, so there was nothing to migrate.

The 3D view now owns Display3DSettings = {"version":1,"gain":N,"span":N},
mirroring the DisplaySourceTraceSettings pattern immediately next to it. Per
your suggestion the legacy Display3DGain is migrated in: read once to seed the
object when none exists, then the object owns the value. One place to default,
one to migrate, one value to write.

Both setters persist the whole object through a single setValue + save()
(Principle XIV). Reset to Defaults goes through resetDisplay3DSettings(),
which writes unconditionally — the setters early-return when a value already
matches, which would otherwise leave a stale object behind on a partial reset.
Profile recall re-applies the object as a unit rather than reading flat keys.

I did not fold in 3D Floor: it's per-source and already owned by
DisplaySourceTraceSettings. Moving it would take it out of one owned object to
put it in another. Say the word if you'd rather they were merged.

Storage: sqlite, confirmed empirically

AppSettings is sqlite-backed (app_settings table); QSettings INI is only a
one-time first-launch migration source and is never written. Verified rather
than assumed:

  • Driving the slider persists
    Display3DSettings = {"gain":70,"span":0,"version":1} — no flat key, and no
    .ini/.conf/.xml created anywhere under the config dir.
  • Seeding a legacy Display3DGain=42 with no object present carries 42 into
    the object on next launch.

Nonblocking

Both done — the slider has an accessible name and description, and the
single-line label update is braced.

Still open, and I'd like your call

The bot's inline review raised two I have not touched, because they're
behavioural rather than governance:

  1. Span collapses on transmit. pushWaterfallRow() pushes supplemental-less
    rows during TX, so age 0 loses its overhang the moment you key up and the
    surface eases back to the trapezoid over ~30 frames — then back out on unkey,
    every over. Real bug, mine. The fix is to take the newest row that actually
    has supplemental rather than age 0 unconditionally.
  2. The slider is inert on the CPU image fallback, with no way for the
    operator to distinguish that from "this source ships no overhang". Worth
    disabling the row with an explanatory tooltip when !m_dssMeshReady.

Happy to take both in this PR — they're small — or split them out. Full build
green, 237/237 tests pass.

…ntrol

on the CPU fallback

Two behavioural findings from the review's inline comments.

The span collapsed on every transmit. dssRowSpanTarget() read age 0 alone,
but pushWaterfallRow() -- the FFT-derived producer that paces rows during TX
and during the RX stale-native fallback -- appends with no supplemental at
all. So the moment a FLEX operator keyed up, the front row lost its overhang,
the target dropped to 1.0, and the eased factor walked the whole surface back
to the clipped trapezoid over ~30 frames, then back out on unkey. Every over.

Take the newest VISIBLE row that actually carries a tile instead. The
per-vertex coverage test in dss_mesh.vert already feathers rows that have no
overhang behind them, so the host never needed the front row to be the one
with data -- that split is the point of the design. Once the last covered row
scrolls out of the visible ring there genuinely is no overhang on screen and
relaxing to the trapezoid becomes the correct answer rather than a flicker.
This covers Kiwi and rows rebuilt from retained history the same way, since
they drop supplemental identically.

The scan lives in DssRenderer::newestSupplementalBandwidthMhz(), which is
where the ring is owned and where it can be tested. The regression test is
mutation-checked: restricting the scan back to age 0 fails "overhang must
survive uncovered rows at the front while covered rows are still on screen".

Second, the 3D Span control was inert on the CPU image fallback, which
ignores rowSpanFactor and always draws the narrowing trapezoid. The slider
moved, the label updated, the setting persisted, and nothing on screen
changed -- indistinguishable to the operator from "this source ships no
overhang". The row is now disabled when the mesh path is unavailable, with a
tooltip naming the reason (no RGBA16F support). Both tooltip wordings live in
setDssRowSpanSupported() so they cannot drift apart.

Full build green, 237/237 tests pass, shader dialects clean, offscreen slider
sweep unchanged.
@ten9876

ten9876 commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator Author

Both inline findings fixed in add2dc2e. Every review item on this PR is now
either resolved or explicitly declined with reasoning.

Span collapsed on every transmit

Confirmed and fixed — this was a real bug and the sharpest functional catch on
the PR. dssRowSpanTarget() read age 0 alone, but pushWaterfallRow() appends
with no supplemental, so keying up dropped the overhang to nothing while ~90
rows of it were still on screen, easing the whole surface back to the trapezoid
and then out again on unkey.

It now takes the newest visible row that actually carries a tile. As the
review noted, the host never needed the front row to be the one with data —
dss_mesh.vert already feathers rows with no overhang behind them, and that
split is the point of the design. Once the last covered row scrolls out of the
visible ring there genuinely is no overhang on screen, so relaxing to the
trapezoid is then correct rather than a flicker. Kiwi and history-rebuilt rows
are covered by the same change.

The scan lives in DssRenderer::newestSupplementalBandwidthMhz() — where the
ring is owned, and where it can be tested. The regression test is
mutation-checked
: restricting the scan back to age 0 reproduces the bug and
fails overhang must survive uncovered rows at the front while covered rows are still on screen.

Span control inert on the CPU fallback

Also fixed. The row is disabled when !m_dssMeshReady, with a tooltip naming
the actual reason (no RGBA16F support) rather than leaving the operator to
guess whether the source simply ships no overhang. Both tooltip wordings live in
setDssRowSpanSupported() so they can't drift apart. invoke on a disabled
widget is refused by the automation bridge, so the no-op is assertable too.

Where the PR stands

Finding Status
rowFrequencyFrames / ClampToEdge on non-FLEX Declined — hardcoded 1.0f, guard active on every backend
Ease could stall with no repaints Fixed
Mesh sparser than the height texture Fixed properly — kMeshCols, static_assert, 20.2 → 33.7 MiB disclosed
Principle V flat key Fixed — owned Display3DSettings, legacy gain migrated
Span collapsed on TX Fixed, mutation-checked
Span control inert on CPU fallback Fixed
Per-frame env re-parse Fixed
applySliceShadow param churn Fixed
vSideDistance comment Fixed
dssRowSpanTarget() untested Fixed — extracted and covered, mutation-checked
Accessible name / braces Fixed
Default 100 vs opt-in Product call, with @ten9876

Full build green, 237/237 tests pass, check_shader_dialects.py clean, and the
offscreen slider sweep is unchanged in character.

@rfoust rfoust self-assigned this Aug 5, 2026

@rfoust rfoust left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up review of the fixes through add2dc2. The original Principle V flat-key blocker is substantially addressed, and the accessibility, easing, mesh-density, environment-cache, TX-collapse, runtime RGBA16F fallback, and test-coverage items all check out in source.

Issue fit remains good: the projection stays frequency-stable while supplemental coverage closes the wedges. One supported renderer configuration still exposes a nonfunctional control, so I am keeping Request changes. The legacy-gain migration note below is nonblocking.

Verification: merged this head without conflicts into current upstream/main b730345 (the GitHub merge ref was one main commit stale); full native arm64 application compile/link passed; dss_renderer_test passed; shader dialects passed; strict engine-boundary check reported 0 blockers; current GitHub Linux/macOS/Windows/static/CodeQL checks are green. Live-radio proof was not rerun.

// Tooltip text lives in setDssRowSpanSupported() so the enabled and
// unavailable wordings cannot drift apart.
m_dssRowSpanSupported = false;
setDssRowSpanSupported(true);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] This still enables an inert control in compile-time CPU builds. AETHER_GPU_SPECTRUM=OFF is supported (and can be selected automatically when the Qt private GPU prerequisites are unavailable), but the only later setDssRowSpanSupported(m_dssMeshReady) call is inside SpectrumWidget.cpp's AETHER_GPU_SPECTRUM block. The software branch never corrects this initial true state, while DssRenderer::rebuild() ignores rowSpanFactor. Result: the slider moves and persists but cannot change the display. Please initialize/force the row unsupported in the non-GPU path as well, ideally with a focused CPU-build configuration check.

m_dssRowSpanPct = 100;

const QString raw = AppSettings::instance()
.value(display3DSettingsKey(), QString()).toString();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P3, nonblocking] The new object fixes the loose Display3DSpan key, but this does not yet perform the claimed read-once migration of legacy Display3DGain. When Display3DSettings is absent, the function seeds the members and returns without saving the object; every later launch reads the flat key again until the operator edits or resets a control. Persist the initialized object only on the absent-object legacy migration path (without overwriting malformed or future-version data), or narrow the migration claim.

@rfoust rfoust removed their assignment Aug 5, 2026
…migration

Follow-up review found two gaps in the previous pass.

The CPU-only build still offered the control. rowSpanFactor is a dss_mesh.vert
uniform, so only the GPU mesh honours it -- but the single
setDssRowSpanSupported(m_dssMeshReady) call lived inside SpectrumWidget's
AETHER_GPU_SPECTRUM block. A build configured with AETHER_GPU_SPECTRUM=OFF
(supported, and selected automatically when the Qt private GPU prerequisites
are missing) never reached it, so the menu kept its enabled default while
DssRenderer::rebuild() ignores the uniform entirely: the slider moved,
labelled and persisted over a display it could not change.

There are two independent routes to the fallback -- the build flag and a
runtime without RGBA16F -- and both must disable the control, so the decision
is now one pure predicate, dssRowSpanSupported(gpuSpectrumBuild, meshReady),
in SpectrumPreviewLogic.h beside the existing host-side config predicates.
The row starts disabled and the GPU path enables it only once
initDssMeshPipeline() has fully succeeded. Covered by a focused CPU-build
configuration check in spectrum_preview_logic_test; the test is
mutation-verified, since dropping the build flag from the predicate fails
"a CPU-only build must never offer the 3D span control".

The legacy migration also did not actually migrate. loadDisplay3DSettings()
seeded the members from the grandfathered flat Display3DGain key when no
object existed but never wrote the object, so the flat key was re-read on
every launch and "the object owns the value" only became true once the
operator happened to touch a control. The absent-object path now persists the
object once, which is the migration.

Malformed and future-version objects are deliberately still left untouched --
overwriting either would destroy the evidence, or fields a newer build
stored. Verified end to end: a launch with legacy Display3DGain=42 and no
object writes {"gain":42,"span":100,"version":1} without any control being
touched, while a corrupt object and a {"version":9,...} object both survive a
launch byte-identical.

Full build green, 237/237 tests pass, shader dialects clean.
@ten9876

ten9876 commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator Author

@rfoust — both addressed in bcbfc8b2. Thanks for catching the CPU-build path;
that one was a genuine hole, not a wording issue.

[P2] Inert control in AETHER_GPU_SPECTRUM=OFF builds

Correct, and my previous fix only closed the runtime half. The single
setDssRowSpanSupported(m_dssMeshReady) call sat inside the GPU block, so a
software build never reached it and kept the enabled default over a fallback
that ignores the uniform.

There are two independent routes to the fallback — the build flag and a runtime
without RGBA16F — so the decision is now one pure predicate,
dssRowSpanSupported(gpuSpectrumBuild, meshReady), in SpectrumPreviewLogic.h
beside the existing host-side config predicates. The row starts disabled and
the GPU path enables it only once initDssMeshPipeline() has fully succeeded,
so neither configuration can leave it enabled by omission.

Per your "ideally with a focused CPU-build configuration check", that's in
spectrum_preview_logic_test. Mutation-verified: dropping the build flag
from the predicate fails a CPU-only build must never offer the 3D span control.

[P3] The migration didn't migrate

Also correct, and my PR text overstated it — thank you for reading the code
rather than the claim. loadDisplay3DSettings() seeded the members from the
flat key but never wrote the object, so the flat key was re-read on every launch
and the object only took ownership once the operator touched a control.

The absent-object path now persists once. Malformed and future-version objects
are still deliberately left untouched, per your parenthetical — overwriting
either would destroy the evidence, or fields a newer build stored.

Verified end to end rather than argued:

Starting state After one launch, no control touched
legacy Display3DGain=42, no object {"gain":42,"span":100,"version":1} written
{not valid json byte-identical, untouched
{"version":9,"gain":33,"span":77,"unknownField":true} byte-identical, untouched

Full review ledger

Every item across all four reviews is now resolved or explicitly declined:

  • Declined (1): rowFrequencyFrames / ClampToEdge on non-FLEX — it's a
    hardcoded 1.0f, so the guard is active on every backend. Your third bot pass
    independently reached the same conclusion and called it "a stronger guarantee
    than the PR description claims".
  • Fixed (13): ease stall · mesh sparser than the height texture · Principle V
    flat key · TX span collapse · CPU-fallback inert control (runtime and
    build) · legacy migration · per-frame env re-parse · applySliceShadow churn ·
    vSideDistance comment · dssRowSpanTarget coverage · accessible name ·
    braces.
  • Open, and not mine to decide: default 100 vs opt-in, with @ten9876.

Three of the fixes carry mutation-verified tests — the TX collapse, the
available-vs-absolute span mapping, and now the CPU-build guard. Those are the
three places where a plausible-looking change silently produces the wrong
behaviour, so I wanted proof the tests fail on the bug rather than merely
passing on the fix.

Known and tracked in #4778, unchanged: the overhang still collapses on pan/zoom
and in scrollback because the retained history ring stores no supplemental.

Full build green, 237/237 tests pass, check_shader_dialects.py clean.

Three reviewers independently read the default of 100 as the risky choice,
since 0 is the reference rendering and the PR frames it that way. It is
deliberate. The control lives in the Display overlay's 3D VIEW section, and
this project's discoverability is not strong enough for an opt-in default to
mean anything other than "most operators never learn the feature exists".
Anyone who prefers the classic narrowing trapezoid has a labelled slider;
anyone who does not know to look gets the intended view.

Comment only -- the default was already 100 at all six sites that express it
(member initialiser, loadDisplay3DSettings, resetDisplay3DSettings, the menu
row, the syncDisplaySettings parameter default, and the reset sync call).
Recorded at the member so the next reviewer finds the reasoning instead of
raising it a fourth time, with the condition attached: do not flip it without
also solving discoverability.
@ten9876

ten9876 commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator Author

Product decision: 3D Span stays defaulted to 100

@ten9876 has ruled on the one item I'd left open. Keeping the default fully on,
with the reasoning: discoverability is not this project's strong suit.

The control sits in the Display overlay's 3D VIEW section. Shipping it at 0
would technically make the new silhouette opt-in, but in practice it would mean
most operators never learn the feature exists. Anyone who prefers the classic
narrowing trapezoid has a labelled slider two clicks away; anyone who doesn't
know to look gets the intended view.

@rfoust and the bot reviews each raised this independently, so I've recorded the
decision and its condition at the member declaration in b9f6aa28 — the next
reader finds the reasoning rather than raising it a fourth time, with "do not
flip this to 0 without also solving the discoverability side" attached.

That commit is comment-only. The default was already 100 at all six sites that
express it — member initialiser, loadDisplay3DSettings,
resetDisplay3DSettings, the menu row, the syncDisplaySettings parameter
default, and the reset sync call — which I verified rather than assumed before
calling this a no-op.

PR status

Every review item across all four passes is now closed:

  • Declined (1): rowFrequencyFrames / ClampToEdge on non-FLEX — hardcoded
    1.0f, guard active on every backend; the third bot pass independently agreed.
  • Fixed (13): ease stall · mesh sparser than the height texture · Principle V
    flat key · TX span collapse · CPU-fallback inert control, runtime and build
    · legacy migration · per-frame env re-parse · applySliceShadow churn ·
    vSideDistance comment · dssRowSpanTarget coverage · accessible name ·
    braces.
  • Product call (1): resolved above.

Three fixes carry mutation-verified tests — the TX collapse, the
available-vs-absolute span mapping, and the CPU-build guard.

Still open and tracked in #4778, unchanged and by design for this PR: the
overhang collapses on pan/zoom and in scrollback, because the retained history
ring stores no supplemental. That's the real follow-up.

Ready for the team test. Full build green, 237/237 tests pass, shader dialects
clean.

@ten9876
ten9876 merged commit 3a1f59e into main Aug 6, 2026
6 checks passed
@ten9876
ten9876 deleted the prototype/dss-frustum-fill branch August 6, 2026 02:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3D Stacked Trace: close the empty wedges flanking the surface with offscreen spectrum

2 participants