From 57b989a8ec009157125625c2089b78bec5b28bd3 Mon Sep 17 00:00:00 2001 From: Robbie Foust Date: Thu, 16 Jul 2026 14:40:33 -0400 Subject: [PATCH 1/2] Add configurable analog meters and harden settings persistence. Principle XIV. --- AGENTS.md | 5 + CMakeLists.txt | 51 + docs/cross-needle-meter-math.md | 236 ++++ resources/meterfaces/README.md | 235 +++- .../meterfaces/analog-meter-themes-v1.json | 150 +++ resources/meterfaces/cross-needle-v12.json | 128 +- resources/meterfaces/s-meter-v1.json | 125 ++ resources/resources.qrc | 2 + src/core/AppSettings.cpp | 368 ++++-- src/core/AppSettings.h | 8 + src/core/AutomationServer.cpp | 26 +- src/gui/AnalogMeterFaceTheme.cpp | 692 ++++++++++ src/gui/AnalogMeterFaceTheme.h | 159 +++ src/gui/AppletPanel.cpp | 57 +- src/gui/AppletPanel.h | 4 + src/gui/CrossNeedleMeterApplet.cpp | 32 + src/gui/CrossNeedleMeterApplet.h | 2 + src/gui/CrossNeedleMeterGeometry.cpp | 773 ++++++++--- src/gui/CrossNeedleMeterGeometry.h | 94 +- src/gui/CrossNeedleMeterSettings.cpp | 8 +- src/gui/CrossNeedleMeterSettings.h | 3 +- src/gui/CrossNeedleMeterWidget.cpp | 334 ++--- src/gui/CrossNeedleMeterWidget.h | 6 + src/gui/MainWindow_Wiring.cpp | 3 +- src/gui/RadioSwrValidityFilter.cpp | 150 +++ src/gui/RadioSwrValidityFilter.h | 45 + src/gui/SMeterGeometry.cpp | 778 +++++++++++ src/gui/SMeterGeometry.h | 177 +++ src/gui/SMeterWidget.cpp | 814 ++++++++---- src/gui/SMeterWidget.h | 68 +- src/gui/SMeterWidgetAccessible.h | 18 + src/gui/VuMeterSettings.cpp | 19 +- src/gui/VuMeterSettings.h | 11 +- tests/TestSettingsProfile.h | 48 + tests/amp_applet_test.cpp | 17 +- tests/antenna_alias_test.cpp | 15 +- tests/app_settings_safety_test.cpp | 364 ++++++ tests/container_manager_test.cpp | 6 + tests/container_nesting_test.cpp | 6 + tests/container_widget_test.cpp | 7 + tests/cross_needle_meter_test.cpp | 900 +++++++++++-- tests/cwx_panel_test.cpp | 7 + tests/flex_control_dialog_size_test.cpp | 6 + tests/help_dialog_test.cpp | 7 + tests/kiwi_sdr_manager_password_test.cpp | 17 +- tests/meter_model_test.cpp | 21 + tests/pan_layout_dialog_size_test.cpp | 7 + tests/passive_spots_policy_test.cpp | 6 + tests/pms_mailbox_test.cpp | 13 +- tests/qso_recorder_slice_lifetime_test.cpp | 7 + tests/s_meter_geometry_test.cpp | 1134 +++++++++++++++++ tests/shortcut_manager_test.cpp | 17 +- tests/slice_label_test.cpp | 6 + tests/theme_manager_test.cpp | 24 +- tests/vu_meter_settings_test.cpp | 104 +- tools/fit_cross_needle_response.py | 207 +++ 56 files changed, 7419 insertions(+), 1108 deletions(-) create mode 100644 docs/cross-needle-meter-math.md create mode 100644 resources/meterfaces/analog-meter-themes-v1.json create mode 100644 resources/meterfaces/s-meter-v1.json create mode 100644 src/gui/AnalogMeterFaceTheme.cpp create mode 100644 src/gui/AnalogMeterFaceTheme.h create mode 100644 src/gui/RadioSwrValidityFilter.cpp create mode 100644 src/gui/RadioSwrValidityFilter.h create mode 100644 src/gui/SMeterGeometry.cpp create mode 100644 src/gui/SMeterGeometry.h create mode 100644 src/gui/SMeterWidgetAccessible.h create mode 100644 tests/TestSettingsProfile.h create mode 100644 tests/app_settings_safety_test.cpp create mode 100644 tests/s_meter_geometry_test.cpp create mode 100644 tools/fit_cross_needle_response.py diff --git a/AGENTS.md b/AGENTS.md index 8e374bc64..ca0db65be 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -218,6 +218,11 @@ Key source directories: `src/core/` (protocol, audio, DSP), `src/models/` TUs; new feature code goes in a sibling, NOT `MainWindow.cpp` — see [Adding code to MainWindow](#adding-code-to-mainwindow) - `PanadapterStream` — VITA-49 UDP parsing, routes FFT/waterfall/audio/meters +- `CrossNeedleMeterGeometry` — PWR applet's cross-needle power/SWR face math. + **Before touching the response model, SWR-contour construction, or label + placement, read [`docs/cross-needle-meter-math.md`](docs/cross-needle-meter-math.md)** — + the authoritative model + decision record (it exists because these formulas + have churned when edited without a shared spec). **Threading:** up to 12 threads — see `docs/architecture/pipelines.md` for the full thread diagram, data flow, cross-thread signal map, and GPU rendering notes. diff --git a/CMakeLists.txt b/CMakeLists.txt index c150a258b..29c28d91e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -776,6 +776,9 @@ set(GUI_SOURCES src/gui/RxApplet.cpp src/gui/AdaptiveFilterControls.cpp src/gui/FilterPassbandWidget.cpp + src/gui/AnalogMeterFaceTheme.cpp + src/gui/RadioSwrValidityFilter.cpp + src/gui/SMeterGeometry.cpp src/gui/SMeterWidget.cpp src/gui/CrossNeedleMeterGeometry.cpp src/gui/CrossNeedleMeterWidget.cpp @@ -2293,6 +2296,30 @@ target_include_directories(theme_manager_test PRIVATE src) target_link_libraries(theme_manager_test PRIVATE Qt6::Core Qt6::Gui Qt6::Widgets Qt6::Test) add_test(NAME theme_manager_test COMMAND theme_manager_test) +# AppSettings persistence safety. Each scenario is a separate process because +# AppSettings is a process-wide singleton and load state must not leak between +# cases. +add_executable(app_settings_safety_test + tests/app_settings_safety_test.cpp + src/core/AppSettings.cpp +) +target_include_directories(app_settings_safety_test PRIVATE src) +target_link_libraries(app_settings_safety_test PRIVATE Qt6::Core) +foreach(APP_SETTINGS_SCENARIO + save-before-load + load-then-save + first-run + count-guard + backup-recovery + missing-live-temp + missing-live-backup + missing-live-corrupt + failed-load) + add_test( + NAME app_settings_safety_${APP_SETTINGS_SCENARIO} + COMMAND app_settings_safety_test ${APP_SETTINGS_SCENARIO}) +endforeach() + add_executable(panadapter_model_rx_antenna_test tests/panadapter_model_rx_antenna_test.cpp src/models/PanadapterModel.cpp @@ -2827,6 +2854,7 @@ add_test(NAME radio_status_ownership_test COMMAND radio_status_ownership_test) qt_add_resources(CROSS_NEEDLE_METER_TEST_RESOURCES resources/resources.qrc) add_executable(cross_needle_meter_test tests/cross_needle_meter_test.cpp + src/gui/AnalogMeterFaceTheme.cpp src/gui/CrossNeedleMeterGeometry.cpp src/gui/CrossNeedleMeterWidget.cpp ${CROSS_NEEDLE_METER_TEST_RESOURCES} @@ -2840,6 +2868,29 @@ add_test(NAME cross_needle_meter_test COMMAND cross_needle_meter_test) set_tests_properties(cross_needle_meter_test PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen") +# Versioned standard S-meter face: responsive geometry, calibrated mappings, +# resource validation/fallback, and offscreen rendering of every meter mode. +add_executable(s_meter_geometry_test + tests/s_meter_geometry_test.cpp + src/gui/AnalogMeterFaceTheme.cpp + src/gui/RadioSwrValidityFilter.cpp + src/gui/SMeterGeometry.cpp + src/gui/SMeterWidget.cpp + src/core/ThemeManager.cpp + src/core/AppSettings.cpp + src/core/LogManager.cpp + src/core/AsyncLogWriter.cpp + ${CROSS_NEEDLE_METER_TEST_RESOURCES} +) +target_include_directories(s_meter_geometry_test PRIVATE src) +target_link_libraries(s_meter_geometry_test PRIVATE + Qt6::Core Qt6::Gui Qt6::Widgets +) +set_target_properties(s_meter_geometry_test PROPERTIES AUTOMOC ON) +add_test(NAME s_meter_geometry_test COMMAND s_meter_geometry_test) +set_tests_properties(s_meter_geometry_test PROPERTIES + ENVIRONMENT "QT_QPA_PLATFORM=offscreen") + # MCP server field-mapping / protocol regression test (#4177). Pure Python, # no app or Qt needed — guards the schema ↔ bridge verb field mapping. find_program(PYTHON3_EXECUTABLE NAMES python3 python) diff --git a/docs/cross-needle-meter-math.md b/docs/cross-needle-meter-math.md new file mode 100644 index 000000000..6bdda7dc8 --- /dev/null +++ b/docs/cross-needle-meter-math.md @@ -0,0 +1,236 @@ +# Cross-needle power/SWR meter — mathematical model & decision record + +**Read this before changing anything about the PWR applet's cross-needle +meter.** This file is the authoritative source of truth for *how the meter +computes and draws forward power, reflected power, the needle crossing, and the +SWR contours* — and, just as importantly, *which modeling choices are settled +and why*. It exists because the model was reshaped by successive edits that each +looked locally reasonable but pulled the geometry in opposite directions. If you +are about to "correct" the physics, your correction must update this document +(and the tests) or it will be reverted by the next contributor. + +Authoritative reference physics: a classic amateur cross-needle meter (Daiwa +CN-series / Diamond SX-series). The governing write-up presents **two** pictures +— an idealized flat-rest model (its §10/§11) and the real-meter deviations (its +§13). Those two pictures imply *opposite* geometries, which is the root cause of +past churn. **This document picks one and records why.** + +--- + +## 0. Where each kind of fact lives (do not duplicate) + +| Fact | Home | Notes | +|---|---|---| +| **Formulas / algorithms** | `src/gui/CrossNeedleMeterGeometry.cpp` `.h` | The only place math lives. | +| **Parameters / calibration data** | `resources/meterfaces/cross-needle-v12.json` | Numbers only, no logic. Compiled into the app via `resources.qrc`. | +| **Calibration fit + diagnostics** | `tools/fit_cross_needle_response.py` | Refits the response coefficients; `--check` guards them. | +| **Executable spec (enforcement)** | `tests/cross_needle_meter_test.cpp` | The tests are the contract. If a change is "correct," it changes the tests deliberately, not incidentally. | +| **Decisions & rationale** | **this file** | Why the model is shaped the way it is. | + +Rule: the JSON never contains logic; the code never hard-codes calibration that +belongs in JSON; comments and READMEs are descriptive, **the tests are +normative.** When the model changes, update all five rows in the same change. + +--- + +## 1. Core electrical quantities + +A directional coupler yields forward/reflected envelope voltages `Vf`, `Vr`: + + rho = Vr/Vf = sqrt(Pr/Pf) 0 <= rho < 1 + SWR = (1 + rho) / (1 - rho) (`swrFromPowers`) + Pr = Pf * rho^2 (`reflectedPowerWatts`) + inverse: rho = (SWR - 1)/(SWR + 1) + +SWR depends only on the ratio `Vr/Vf`, not on absolute power — this is why one +fixed family of printed contours reads SWR at any power. Unchanged across all +revisions; do not touch. + +--- + +## 2. Detector + movement response `angleForValue()` + +Needle deflection is a degree-5 Bernstein of **normalized power** — a real +D'Arsonval movement driving a square-root watt scale: + + p = (P - Pmin)/(Pmax - Pmin) // normalized power + r = degree-5 Bernstein(p; responseCoefficients[0..5]) // monotonic + concave + angle = lerp(responseStartRadians, responseEndRadians, r) + +`responseModel` = `concave_bernstein_v1`. The coefficients (non-decreasing, with +non-positive second differences → concave, square-root-like compression) and the +start/end angles live in each scale's `response` block in the JSON. Two entry +points share this Bernstein: **`angleForValue`** clamps `p` to `[0, 1]` for live +needles and printed ticks (a movement never reads past full scale), while +**`angleForNormalizedPower`** does NOT clamp — SWR-contour construction passes +`p` slightly over 1 to extend a movement a little past full scale (§4). + +**Angled rest (the key property).** `responseStartRadians` equals the scale's +printed-zero angle (`reference_angles_radians[0]` == `startRadians`). At zero +power `r = 0`, so each needle parks *on its printed 0 mark, pointing up into the +dial* — exactly like a real cross-needle meter. It does NOT rest flat on the +concealed pivot baseline. This is what keeps the low-SWR contours visible and +shallow (see Decision **D1**). `printedAngleForIndex()` therefore returns the +calibrated movement angle for every tick, including 0. + +--- + +## 3. Needle rays and their crossing + +Pivots are concealed below the visible face (`forwardScale.center`, +`reflectedScale.center`, both below the canvas). Each needle is a straight ray +from its pivot at the deflection angle; the tip rides a circle of radius += needle length: + + tip = center + (cos(angle), sin(angle)) * radius (`pointOnScale`) + crossing = line-intersection(fwd pivot→fwd tip, ref pivot→ref tip) (`needleIntersection`) + +The mechanism only ever does this: two rays, one crossing. SWR is *read* off the +printed contour that passes through the crossing, never computed by a third +movement. Verified against a stored proof case in the tests +(`validation.intersection`). + +--- + +## 4. Constant-SWR contours `swrGuidePath()` + +A contour is the locus of crossings at fixed SWR as power sweeps up from the +hidden convergence: + + ratio = ((SWR-1)/(SWR+1))^2 = rho^2 = Pr/Pf (infinity → ratio = 1) + sweep forward power up (extending past full scale via angleForNormalizedPower) + point = crossing of fwd needle at P and ref needle at P*ratio + // terminate on ONE common boundary: a fixed graph_clearance short of + // whichever power arc is nearer (design 19 — a consistent fan; see D1). + +Within the readable range a contour is an **exact** constant-SWR locus: the +crossing at true SWR = S at any readable power lands on the S contour, so the +printed grid reads correctly. The short segment past full scale (needed only by +the low-SWR contours to reach the common boundary) is **drawn face art**, not a +readable locus — the ratio-exactness tests therefore verify only the readable +portion. Contours are **not** circular arcs. + +--- + +## 5. SWR number placement `swrLabelCenters()` + +Derived, not authored. There are **no per-guide label coordinates.** Each number +anchors on its own contour at a fraction `label_arc_fraction` of that contour's +**visible** arc length (from where it clears the mask to its upper end), then is +decluttered: + +1. Anchor at `label_arc_fraction` up the visible contour; if the box collides, + step along the contour by `label_declutter_step` (alternating ±, and past the + top along the upper tangent for a stub too short to host a box, e.g. 1.1), + and along the contour normal (alternating ±), until the box is clear. +2. "Clear" = keeps `label_box_padding` from every already-placed label, every + *other* contour path, the power-scale number boxes, the lower mask, and the + face edge. +3. Result is memoized in `swrLabelCenterCache`, keyed by the exact `QFont` used + by the widget. A widget/application font change therefore recomputes the + collision layout before rebuilding the static face. + +To move the whole number ring, change **one** value: `label_arc_fraction`. The +tests enforce association (each number nearest its own contour or its upper +continuation), spacing, non-overlap, mask clearance, and clearance from the +reflected-scale numbers. + +--- + +## 6. Decisions (settled — change only by editing this section + the tests) + +### D1 — Angled rest → single hidden convergence → shallow, visible low-SWR lines +**Status: ADOPTED** (this supersedes the earlier flat-rest decision; see the +changelog at the end). Implemented by `concave_bernstein_v1` with the response +starting at the angled printed-zero rest (§2). + +The needles park on their printed zeros, pointing up into the dial. At zero +power both needles are angled (not colinear on the baseline), so every +constant-SWR contour begins at the **same** hidden crossing just below the lower +mask (~`(750, 979)`) and fans upward. The low-SWR lines ride up **along the +reflected needle's rest ray** — visible above the mask and rising at a shallow +angle that steepens toward SWR = ∞ (measured: 1.1 ≈ 19°, 1.2 ≈ 32°, 1.5 ≈ 62°). + +**Why this model (motivation, not proof).** The real Daiwa **CN-801** face +(its manual's 第2図, https://sq7fpd.boff.pl/CN801.pdf) shows the SWR 1.1–1.5 +lines visible and shallow and the fan converging near the bottom — which is what +motivated the angled-rest choice over the idealized flat-rest picture (the +reference write-up's §13 vs §10/§11). Be honest about what the manual actually +establishes: it confirms SWR is read at the needle crossing and that 10 W / 0.4 W +reads SWR 1.5 (our `validation` proof asserts exactly this). It does **not** +document the detector response, the single hidden convergence, or the +past-full-scale extension — those are our engineering choices, informed by the +face's appearance, not proven by the manual. Treat them as settled-by-decision +here, not as facts derived from the source. + +**Retired model (do NOT reintroduce without flipping this decision + the tests):** +a `monotonic_voltage_bernstein_v1` response that rested both needles flat on the +pivot baseline (forward −π, reflected 0) so contours left independent baseline +origins vertically (§10). That is mathematically clean but it pins the low-SWR +crossings onto the baseline: SWR 1.1/1.2 fell entirely below the mask and 1.3–2 +drew as steep stubs — visibly wrong against the CN-801. The tests now assert the +angled-rest behaviour (`all contours share one convergence concealed below the +lower mask`, `low-SWR contours (1.1, 1.2) reach the visible face`, `low-SWR lines +emerge shallow and steepen toward higher SWR`). + +### D2 — SWR labels are derived, never authored per-guide +**Status: ADOPTED.** See §5. An SWR guide in JSON stores only +`{label, display_label, swr}`. The three global knobs +(`label_arc_fraction`, `label_declutter_step`, `label_box_padding`) plus the +declutter algorithm produce every position. This replaced 24 hand-tuned +`label_y`/`label_offset` pixel values that had to be re-nudged after any +geometry change. + +### D3 — JSON is data, code is math, tests are the contract +**Status: ADOPTED.** See §0. A change to the physics is not "done" until the +formula (code), any parameters (JSON), the fit tool, this document, and the +tests all agree in the same change. + +--- + +## 7. Changing the model safely — checklist + +1. State the intent here (edit the relevant Decision, or add a new one). +2. Change the formula in `CrossNeedleMeterGeometry.cpp`. +3. If calibration changed: re-run `tools/fit_cross_needle_response.py`, paste the + coefficients into the JSON, and confirm `--check` passes. +4. Update `tests/cross_needle_meter_test.cpp` to assert the new behavior *on + purpose*, and `resources/meterfaces/README.md` for the parameter docs. +5. Bump `format_version`/`design_version` in the JSON if the schema/geometry + meaning changed. +6. Rebuild and run `cross_needle_meter_test` — it is the acceptance gate. + +--- + +## 8. Changelog + +- **design_version 19 — consistent fan: all contours terminate on a common + boundary.** Every contour now sweeps until its crossing reaches one shared + envelope a fixed `graph_clearance` short of whichever power arc is nearer, so + the whole SWR family is an even fan parallel to the arcs (matches the CN-801). + Low-SWR crossings reach that boundary only a little past full forward scale, + so `angleForNormalizedPower` extends the movement angle modestly past unity + (≤ ~2.5× full-scale power) for contour drawing only — **live needles and the + power ticks still clamp at full scale.** Consequence: a contour is an exact + constant-SWR locus within the readable range and drawn face-art beyond it; the + ratio-exactness tests verify only the readable portion (see D3 note). + **`format_version` bumped 5 → 6** here: the `response` and `swr` schema changed + across designs 16→19 (removed `linear_end_voltage`/`blend_end_voltage`, renamed + `label_arc_radius` → `label_arc_fraction`, added `mask_gap`), so a stale + format-5 file is now rejected at the format gate rather than on content. +- **design_version 18 — contours reach the arcs + leading-end mask gap.** + `graph_clearance` reduced (154 → 60) so the high-SWR contours extend close to + the power arcs instead of stopping far short. Low-SWR contours are unchanged + (they stop at full forward scale — the readable limit). Added `mask_gap` (a + render-only trim, applied in `CrossNeedleMeterWidget::drawSwrGuides`) so every + contour's lower end sits a small gap above the mask, matching the real face. +- **design_version 17 — angled-rest / shallow low-SWR (D1 flipped).** Moved the + needle zero-rest from the flat pivot baseline back to the angled printed-zero + position (`concave_bernstein_v1`, power domain). Reason: the flat-rest model + (design 16) hid the SWR 1.1/1.2 contours below the mask and drew 1.3–2 as + steep stubs, contradicting the real Daiwa CN-801 face. Now the low-SWR lines + are visible and shallow. Also: SWR label anchor changed from an absolute + arc-length (`label_arc_radius`) to a fraction of each contour's visible length + (`label_arc_fraction`), so it adapts to the single-origin fan. +- **design_version 16 — flat-rest / spread-origin (retired).** See D1's retired + model. Introduced the voltage-domain response and vertical baseline departure. diff --git a/resources/meterfaces/README.md b/resources/meterfaces/README.md index 24b840ac8..fdf2e0e36 100644 --- a/resources/meterfaces/README.md +++ b/resources/meterfaces/README.md @@ -1,10 +1,107 @@ -# Cross-needle meter geometry +# Meter-face geometry + +## Standard S-meter + +`s-meter-v1.json` is the editable source of truth for the standard S-meter's +responsive face construction. It preserves the original `SMeterWidget` +formulas rather than imposing the cross-needle meter's fixed design canvas: +the arc radius and horizontal center scale from widget width, while the arc +center height, typography, pivot, and readouts retain their established +width/height-relative or pixel offsets. + +The file owns the shallow-arc sweep, RX calibration, selectable TX scale +ticks, tick/label placement, needle and shadow dimensions, pivot/glow sizing, +peak overlays, and readout placement. The established **Aether default** face +continues to use `SMeterWidget` and `ThemeManager` colors. The three physical +faces use the separate shared catalog described below, so geometry edits cannot +accidentally change theme behavior. + +The mechanical construction has one authority. A value first selects its +calibrated point on the outer scale arc. The line from the concealed movement +pivot through that point is the value's movement ray. The needle extends along +that ray, and the inner TX tick is placed where the same ray intersects the +inner scale arc. Peak markers use the same construction. Do not calculate a +needle endpoint by extending a radius from the scale arc's center: the arc +center and physical movement pivot are intentionally different points. + +Important rules: + +- Keep the RX calibration anchors mechanically consistent: S0 is -127 dBm, + S9 is -73 dBm at fraction 0.6, and S9+60 is -13 dBm at fraction 1.0. +- `center_y_height_factor` participates in + `centerY = radius + height * factor`; it is not an absolute normalized Y. +- Power tick policies are ordered from the largest threshold to the 0 W + default. The radio-selected maximum and warning point remain live widget + state; this resource controls only how that scale is printed. +- Every version-1 field is required and type-checked. A missing, mistyped, or + mechanically invalid field rejects the complete resource; fields are never + silently mixed with compiled defaults. +- Keep the JSON and `SMeterGeometry::fallback()` exactly aligned. The test + compares every field. The JSON is the shipping authority; the complete + fallback is selected only when the complete resource is rejected, so a + damaged resource cannot crash, distort, or blank the applet. +- The pivot mask uses `pivot.radius_width_factor`, but its effective width is + capped by the preferred face aspect ratio from `sizing`. This preserves the + approved mask at normal sizes while preventing it and its glow from + ballooning when a detached window is made much wider without becoming taller. +- `readout.top_extra_pixels` is the minimum gap above the larger left/right + value font. The smaller centered source label is top-aligned against those + values using the fonts' actual ascents, maximizing its clearance from the + printed arc at minimum, docked, or detached sizes. +- `sizing.minimum_aspect_ratio` and `maximum_aspect_ratio` bound only the + face's render viewport. Normal widget sizes retain the original responsive + formulas exactly; pathological tall or wide detached windows center a + bounded face instead of stretching the concealed pivot outside its arcs or + scaling labels until they collide. The lower bound remains below the normal + docked aspect ratio, so the standard applet is pixel-for-pixel unchanged. + +After editing, run `s_meter_geometry_test` and use the automation bridge to +grab `standardSMeter` at its normal, minimum, and enlarged sizes. + +## Shared physical analog themes + +`analog-meter-themes-v1.json` is the runtime source of truth for the physical +**Classic warm**, **Dark-room uplight**, and **Graphite dark** materials shared +by the standard S-meter and the PWR cross-needle meter. It owns only visual +materials: the deterministic gradients, fixed-seed paper grain, ink and text +palettes, needle colors, and the approved symmetric nine-point lower mask. It +does not own either meter's calibration, arcs, ticks, or movement geometry. + +The gradient coordinates use the PWR meter's 1448 x 948 face rectangle. The +shared painter maps that reference rectangle to the target widget, so both +meters receive the same material recipe without storing or stretching a raster +image. The grain texture is generated deterministically and expensive material +layers are cached by the widgets. + +The lower-mask boundary is normalized to its target face. Keep its nine points +strictly left-to-right and mirrored around x = 0.5. The standard S-meter uses +this profile only for the three physical themes; **Aether default** deliberately +retains its existing half-disc mask. + +Classic warm and Dark-room uplight use the dark layered needle palette; +Graphite dark uses the restrained light metallic palette. These colors change +only the material stack around the already-calibrated pivot-to-tip line. The +standard meter's needle tip still comes exclusively from `s-meter-v1.json`, and +the PWR meter's two needle rays still come exclusively from +`cross-needle-v12.json`. + +For format compatibility, `cross-needle-v12.json` still carries its original +V12 appearance values. They are no longer the runtime authority for shared +background or palette colors; edit `analog-meter-themes-v1.json` and its full +compiled fallback together. A future cleanup can remove those compatibility +fields without mixing that migration into a visual theme change. + +## Cross-needle PWR meter `cross-needle-v12.json` is the editable source of truth for the PWR applet's cross-needle Forward / Reflected power and SWR face. It is intentionally kept as data rather than buried as painter constants so the face can be refined without reverse-engineering `CrossNeedleMeterWidget`. +The filename is a stable resource URI retained for compatibility. The payload's +authoritative `format_version` and `design_version` fields are currently 6 and +19; inspect those fields rather than inferring either version from the filename. + The geometry is expressed in a 1500 x 1000 design coordinate system. The widget applies one uniform scale and centers that design in its available rectangle; never compensate for a different widget aspect ratio by changing @@ -12,12 +109,11 @@ individual points. Important construction rules: -- `face_gradient` defines the old-instrument material entirely in code: a - three-stop vertical base, one broad center backlight, and one symmetric edge - vignette. All centers and radii use design coordinates. The painter also - reuses this exact construction when clearing the upper scale underpass and - the SWR label boxes, so those regions never become flat-color patches. -- `uplight_gradient` defines the selectable **Dark-room uplight** treatment: +- The runtime background materials and palettes come from + `analog-meter-themes-v1.json`. The older `face_gradient`, `uplight_gradient`, + and `dark_theme` blocks remain compatibility data in the stable geometry + resource; they are not the runtime material authority. +- The shared `uplight_gradient` defines the selectable **Dark-room uplight** treatment: one low-exposure vertical card gradient, a broad bottom-center halo, a smaller warm hotspot just behind the lower mask, a tight paper-diffusion bloom, and a symmetric vignette. The broad halo has an additional shoulder @@ -31,30 +127,48 @@ Important construction rules: being designed. `scale_separator_rgba` replaces the classic face's opaque cream graph separator with translucent copper so the uplight remains visible through the complete left and right scale bands. -- `dark_theme` defines the selectable **Graphite dark** treatment. It combines +- The shared `dark_gradient` and Graphite palette define the selectable + **Graphite dark** treatment. It combines a matte charcoal card, a restrained neutral ambient lift, a low amber edge glow, a symmetric vignette, and scale-stable fixed-seed paper/paint grain. The same block owns its aged-ivory scale/text colors, copper SWR ink, steel-blue inner rules, dark mask palette, and metallic needle colors. These are material overrides only; all arcs, ticks, guides, pivots, and calibration remain shared. -- `needles` defines a code-drawn physical material stack without changing the +- `needles` defines the PWR meter's code-drawn physical material dimensions + without changing the calibrated movement rays. `line_*` is the exact pivot-to-tip body; `soft_shadow_*` and `shadow_*` form a restrained two-depth cast shadow; `edge_*` shades the side opposite the light; and `highlight_*` adds a narrow - reflection on the upper-left edge. Edge offsets are measured perpendicular + reflection on the upper-left edge; its colors come from the shared palette. + Edge offsets are measured perpendicular to each needle, while shadow offsets are ordinary design-space x/y values. Keep both edge strokes narrower than `line_width` and the soft shadow wider than the contact shadow so the effect survives at 260 x 173 without reading as a bevel or glow. - `forward_scale.center` is the concealed right-hand movement pivot and `reflected_scale.center` is the concealed left-hand movement pivot. -- Tick angles are calibrated and non-linear. Keep `values`, `angles`, and - `labels` the same length and do not replace them with evenly-spaced angles. - The renderer derives a shape-preserving monotone cubic movement curve from - those ticks. It passes through every stored calibration angle while keeping - the first derivative continuous, so live needle motion does not form a - visible elbow at each calibration interval. +- `reference_angles_radians` preserves the perspective-corrected photographic + tick observations; it is fitting evidence, not runtime calibration. Keep it, + `values`, and `labels` the same length. Each scale's `response` is the sole + movement authority used to derive major ticks, power-valued minor ticks, + live needles, inverse readings, and SWR construction. + `concave_bernstein_v1` is a normalized degree-5 Bernstein response in + normalized POWER (a real D'Arsonval movement with a sqrt watt scale). Its six + controls are non-decreasing with non-positive second differences (concave, + square-root-like compression). `response.start_radians` is the ANGLED rest = + the printed-zero angle (`reference_angles_radians[0]` == `start_radians`), so + at zero power the needle parks on its printed 0 mark pointing up into the dial + like a real cross-needle meter — it does NOT rest flat on the pivot baseline. + Positive-power tick fits deviate from the perspective-corrected observations + by at most ~26 design pixels on Forward and ~19 on Reflected (a few pixels at + the normal applet width), within the declared `maximum_reference_error_pixels`. + See `docs/cross-needle-meter-math.md` (Decision D1) for why angled rest, not + flat-baseline rest, is the settled model. +- Verify response edits with `python3 tools/fit_cross_needle_response.py --check`. + The dependency-free utility reports positive-tick residuals plus, for every + guide, its curvature reversals and how many samples reach the visible face + (low-SWR contours must be visible, mirroring the real meter). - `scale_overlap` controls the upper crossing. The reflected trajectory is one continuous circle, but a short flat-ended mask is applied at its crossing before Forward is overprinted. Adjust `reflected_gap_half_span_radians` to @@ -69,34 +183,81 @@ Important construction rules: - `range.label` accepts explicit `\n` line breaks. Keep each multiplier on its own short line in the open top-right area instead of extending text back toward the right `(W)` label or upper power graphs. +- The Range legend's visibility is a client preference in the versioned + `CrossNeedleMeter` AppSettings object, not face geometry. The right-click + **Show Range** action defaults on and may hide only this printed legend; + range selection, multiplier calibration, and needle positions are unchanged. - `typography` contains every face font size in design pixels. These values are intentionally larger than the photographic proof so labels survive the normal 260 x 173 applet size; positions remain controlled by the geometry. -- Each SWR guide retains the approved V12 source construction directly: - `visible_upper`, the registered `registered_datum` at y = 880, - `hidden_lower`, the source-fitted `quadratic_a`, and an independent - `label_center`. The visible segment is an exact quadratic x(y) with at most - six design pixels of bow; a C1 Hermite segment continues it behind the mask. - Three source guides (`infinity`, `8`, and `1.7`) are deliberately straight. - Do not regenerate this fan from the movement calibration: that discarded - the perspective-corrected physical tracing and produced large S-shaped - curves absent from both the source meter and approved V12 proof. -- The lower mask deliberately hides the movement centers and the convergence - of the SWR constructions. Its boundary is symmetric about x = 750. +- An SWR guide stores only its semantic `swr` value; its number's position is + derived, not authored (see `label_arc_fraction` below). Its curve is generated + deterministically from the two calibrated movement maps. For finite SWR, + reflected/forward power is + `((SWR - 1) / (SWR + 1))^2`; the infinity guide uses a ratio of 1. Every + sampled point is the intersection of the Forward needle at `F` and the + Reflected needle at `F * ratio`. Sampling advances uniformly in Forward + detector voltage, so screen-space segments do not bunch according to power. + This is why some guides have a slight, smooth curve: the two printed power + scales are non-linear. +- All SWR guides share ONE hidden convergence. Because the needles rest angled + (on their printed zeros), the zero-power crossing is a single point just below + the lower mask (~`(750, 979)`) that every contour fans up from. The low-SWR + lines (1.1–1.5) rise from there along the reflected needle's rest ray — + visible above the mask and shallow, steepening toward SWR = ∞. This mirrors + the real Daiwa CN-801 face. +- `curve_samples` controls the polyline resolution. It must be high enough that + straight segments between exact movement intersections preserve the target + ratio after rasterization. It only bounds path approximation error; it cannot + cure a noisy movement response and must not be used as a substitute for the + smooth response fit. At 256 samples the current maximum midpoint deviation + from the exact constant-ratio locus is below 0.019 design pixel. +- `graph_clearance` is the visual inset from the nearer power-scale arc at which + EVERY contour terminates, so the whole SWR family is a consistent fan parallel + to the arcs (smaller = closer to the arcs). Low-SWR crossings reach that + boundary only a little past full forward scale, so contour drawing extends the + movement angle modestly past unity to get there (live needles and ticks still + clamp at full scale). A contour is thus an exact SWR locus within the readable + range and drawn face-art beyond it. +- `mask_gap` trims the leading (lower) end of every contour this many design + pixels above the lower mask, leaving a small gap between the line and the mask + like the real meter face. It is a render trim only; the geometry path is + unchanged, so it does not affect SWR readings or label placement. +- SWR numbers are placed by one derived rule, not per-guide coordinates. Each + number anchors at fraction `label_arc_fraction` of its contour's VISIBLE arc + length (from the mask crossing to the upper end). It then searches along the + contour — and past the top along the contour's smooth upper tangent when a + low-SWR stub is too short to host a box (e.g. 1.1) — and along the contour + normal. `label_declutter_step` is the search increment; `label_box_padding` + the required gap from other labels. Placement is part of resource validation: + a box that cannot clear the mask, another number, a power-scale number, or + another SWR guide makes the geometry invalid instead of silently accepting the + last attempted position. +- The lower mask deliberately hides the two movement centers and the shared + convergence. Its boundary is symmetric about x = 750. - Both power scales use the same range multiplier: x1 = 20 W, x10 = 200 W, and x100 = 2 kW. After editing, run `cross_needle_meter_test` and use the automation bridge to -grab `crossNeedleMeter`. The validation block records the approved V12 proof: +grab `crossNeedleMeter`. The validation block records a mechanical proof: 100 W forward and SWR 1.5 derive 4 W reflected and intersect the printed 1.5 -guide within two design pixels. +guide within half a design pixel. -The test suite verifies all 12 registered guide paths, their straight/curved -classification, six-pixel bow limit, C1 hidden continuation, label anchors, -and the approved 100 W / 4 W active intersection. This prevents a mechanically -recomputed fan from replacing the perspective-corrected source geometry. +The test suite verifies the ratio along all 12 guides and between their sampled +points, that all contours share one convergence concealed below the mask, that +the low-SWR contours (1.1, 1.2) reach the visible face and the low-SWR lines +emerge shallow and steepen toward higher SWR, monotonic concave response +coefficients, needles parked on their printed zeros, exact positive-tick/ +live-needle alignment, explicit reference-observation budgets, at most one gentle +curvature inflection per guide, the 0.03-pixel path-approximation budget, +calibrated endpoints, ordering, range-multiplier invariance, every label's +own-contour or tangent-continuation association and full font-box clearances, and +both the 10 W / 0.4 W x1 and 100 W / 4 W x10 SWR 1.5 intersections. It also +renders every material theme and checks the physical needle layers over the +corrected guide ink. -The construction was derived from perspective-corrected measurements of a -Daiwa-style physical cross-needle meter and checked against manufacturer -documentation. It intentionally contains no product logo or copied face -artwork. +The face proportions and visual envelope were derived from +perspective-corrected measurements of a Daiwa-style physical cross-needle +meter. Mechanical SWR calibration comes from the stored movement scales and +the power-ratio relationship above. The resource intentionally contains no +product logo or copied face artwork. diff --git a/resources/meterfaces/analog-meter-themes-v1.json b/resources/meterfaces/analog-meter-themes-v1.json new file mode 100644 index 000000000..52e9ac9fb --- /dev/null +++ b/resources/meterfaces/analog-meter-themes-v1.json @@ -0,0 +1,150 @@ +{ + "format_version": 1, + "reference_face": [26.0, 26.0, 1448.0, 948.0], + "lower_mask": { + "boundary_normalized": [ + [0.0179558011, 0.9261603376], + [0.1546961326, 0.9261603376], + [0.3273480663, 0.9219409283], + [0.4309392265, 0.9008438819], + [0.5000000000, 0.8924050633], + [0.5690607735, 0.9008438819], + [0.6726519337, 0.9219409283], + [0.8453038674, 0.9261603376], + [0.9820441989, 0.9261603376] + ], + "bottom_normalized": 1.0 + }, + "classic_gradient": { + "top_rgba": [232, 216, 183, 255], + "middle_rgba": [250, 242, 221, 255], + "bottom_rgba": [222, 205, 172, 255], + "middle_stop": 0.50, + "glow_center": [750.0, 400.0], + "glow_radius": 900.0, + "glow_inner_rgba": [255, 250, 235, 205], + "glow_outer_rgba": [255, 250, 235, 0], + "vignette_center": [750.0, 440.0], + "vignette_radius": 930.0, + "vignette_clear_stop": 0.62, + "vignette_edge_rgba": [106, 97, 76, 65], + "paper_grain_opacity": 0.0 + }, + "uplight_gradient": { + "top_rgba": [142, 110, 73, 255], + "middle_rgba": [174, 126, 70, 255], + "bottom_rgba": [169, 119, 65, 255], + "middle_stop": 0.60, + "halo_center": [750.0, 920.0], + "halo_radius": 930.0, + "halo_inner_rgba": [255, 181, 83, 180], + "halo_middle_rgba": [242, 160, 72, 170], + "halo_middle_stop": 0.52, + "halo_shoulder_rgba": [218, 143, 68, 40], + "halo_shoulder_stop": 0.72, + "halo_outer_rgba": [211, 137, 69, 0], + "hotspot_center": [750.0, 930.0], + "hotspot_radius": 470.0, + "hotspot_inner_rgba": [255, 245, 110, 220], + "hotspot_middle_rgba": [255, 195, 65, 145], + "hotspot_middle_stop": 0.48, + "hotspot_outer_rgba": [255, 146, 44, 0], + "bloom_center": [750.0, 940.0], + "bloom_radius": 330.0, + "bloom_inner_rgba": [255, 246, 95, 225], + "bloom_middle_rgba": [255, 215, 65, 90], + "bloom_middle_stop": 0.52, + "bloom_outer_rgba": [255, 220, 130, 0], + "vignette_center": [750.0, 650.0], + "vignette_radius": 890.0, + "vignette_clear_stop": 0.34, + "vignette_edge_rgba": [9, 10, 12, 120], + "paper_grain_opacity": 0.100 + }, + "dark_gradient": { + "top_rgba": [20, 22, 23, 255], + "middle_rgba": [27, 28, 28, 255], + "bottom_rgba": [25, 24, 23, 255], + "middle_stop": 0.58, + "ambient_center": [750.0, 410.0], + "ambient_radius": 900.0, + "ambient_inner_rgba": [68, 66, 61, 55], + "ambient_outer_rgba": [40, 40, 38, 0], + "glow_center": [750.0, 940.0], + "glow_radius": 720.0, + "glow_inner_rgba": [190, 92, 35, 100], + "glow_middle_rgba": [112, 59, 34, 38], + "glow_middle_stop": 0.58, + "glow_outer_rgba": [80, 43, 30, 0], + "vignette_center": [750.0, 500.0], + "vignette_radius": 940.0, + "vignette_clear_stop": 0.42, + "vignette_edge_rgba": [0, 0, 0, 120], + "paper_grain_opacity": 0.220 + }, + "palettes": { + "classic-warm": { + "ribbon_rgba": [224, 212, 187, 225], + "scale_outer_rgba": [30, 36, 38, 255], + "scale_separator_rgba": [246, 237, 216, 255], + "scale_calibration_rgba": [126, 62, 54, 235], + "scale_inner_rgba": [48, 65, 91, 255], + "major_tick_rgba": [35, 42, 46, 255], + "minor_tick_rgba": [55, 72, 91, 230], + "text_rgba": [35, 42, 46, 255], + "secondary_text_rgba": [54, 61, 66, 255], + "swr_guide_rgba": [161, 74, 58, 230], + "swr_label_rgba": [45, 45, 42, 255], + "needle_rgba": [18, 22, 25, 255], + "needle_edge_rgba": [2, 4, 5, 210], + "needle_highlight_rgba": [132, 132, 124, 175], + "needle_shadow_rgba": [0, 0, 0, 80], + "needle_soft_shadow_rgba": [0, 0, 0, 28], + "mask_fill_rgba": [34, 40, 47, 255], + "mask_edge_rgba": [87, 99, 110, 255], + "mask_text_rgba": [234, 238, 241, 255] + }, + "dark-room-uplight": { + "ribbon_rgba": [224, 212, 187, 225], + "scale_outer_rgba": [30, 36, 38, 255], + "scale_separator_rgba": [117, 75, 48, 150], + "scale_calibration_rgba": [126, 62, 54, 235], + "scale_inner_rgba": [48, 65, 91, 255], + "major_tick_rgba": [35, 42, 46, 255], + "minor_tick_rgba": [55, 72, 91, 230], + "text_rgba": [35, 42, 46, 255], + "secondary_text_rgba": [54, 61, 66, 255], + "swr_guide_rgba": [161, 74, 58, 230], + "swr_label_rgba": [45, 45, 42, 255], + "needle_rgba": [18, 22, 25, 255], + "needle_edge_rgba": [2, 4, 5, 210], + "needle_highlight_rgba": [132, 132, 124, 175], + "needle_shadow_rgba": [0, 0, 0, 80], + "needle_soft_shadow_rgba": [0, 0, 0, 28], + "mask_fill_rgba": [34, 40, 47, 255], + "mask_edge_rgba": [87, 99, 110, 255], + "mask_text_rgba": [234, 238, 241, 255] + }, + "graphite-dark": { + "ribbon_rgba": [44, 43, 40, 225], + "scale_outer_rgba": [202, 188, 159, 255], + "scale_separator_rgba": [126, 95, 73, 225], + "scale_calibration_rgba": [119, 71, 59, 235], + "scale_inner_rgba": [59, 84, 105, 255], + "major_tick_rgba": [205, 191, 162, 255], + "minor_tick_rgba": [151, 139, 117, 230], + "text_rgba": [205, 190, 158, 255], + "secondary_text_rgba": [179, 162, 132, 255], + "swr_guide_rgba": [139, 77, 58, 225], + "swr_label_rgba": [205, 190, 158, 255], + "needle_rgba": [202, 197, 183, 255], + "needle_edge_rgba": [82, 78, 69, 220], + "needle_highlight_rgba": [240, 234, 216, 255], + "needle_shadow_rgba": [0, 0, 0, 105], + "needle_soft_shadow_rgba": [0, 0, 0, 36], + "mask_fill_rgba": [20, 23, 26, 255], + "mask_edge_rgba": [64, 69, 74, 255], + "mask_text_rgba": [219, 207, 181, 255] + } + } +} diff --git a/resources/meterfaces/cross-needle-v12.json b/resources/meterfaces/cross-needle-v12.json index 2fc44bf9c..e9fe34cd0 100644 --- a/resources/meterfaces/cross-needle-v12.json +++ b/resources/meterfaces/cross-needle-v12.json @@ -1,6 +1,6 @@ { - "format_version": 1, - "design_version": 12, + "format_version": 6, + "design_version": 19, "canvas": { "width": 1500.0, "height": 1000.0 }, "frame": { "outer_inset": 6.0, @@ -136,7 +136,14 @@ "start_radians": -2.922529943067865, "end_radians": -1.6309954106531042, "values": [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 12.0, 14.0, 16.0, 18.0, 20.0], - "angles_radians": [-2.922529943067865, -2.7575556577103635, -2.653074551777382, -2.5618442190472583, -2.4858302805855743, -2.407647882308845, -2.334921104168284, -2.2667679859484724, -2.202570894238984, -2.142006166065045, -2.091917422015382, -2.0183544582402044, -1.9426720825833272, -1.862655454785555, -1.7811567909659995, -1.7009954106531042], + "reference_angles_radians": [-2.922529943067865, -2.7575556577103635, -2.653074551777382, -2.5618442190472583, -2.4858302805855743, -2.407647882308845, -2.334921104168284, -2.2667679859484724, -2.202570894238984, -2.142006166065045, -2.091917422015382, -2.0183544582402044, -1.9426720825833272, -1.862655454785555, -1.7811567909659995, -1.7009954106531042], + "response": { + "model": "concave_bernstein_v1", + "start_radians": -2.922529943067865, + "end_radians": -1.7009954106531042, + "coefficients": [0.0, 0.4833365491938737, 0.6144073900045368, 0.7454782308151997, 0.8727391154075999, 1.0], + "maximum_reference_error_pixels": 28.0 + }, "labels": ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "12", "14", "16", "18", "20"], "minor_subdivisions": 5, "label_offset": 52.0 @@ -147,7 +154,14 @@ "start_radians": -0.21906271052192816, "end_radians": -1.510597242936689, "values": [0.0, 0.2, 0.4, 0.6, 0.8, 1.0, 1.2, 1.4, 1.6, 1.8, 2.0, 2.4, 2.8, 3.2, 3.6, 4.0], - "angles_radians": [-0.21906271052192816, -0.28884457131740043, -0.37052366107858065, -0.4545569503357463, -0.5365773955295918, -0.6171645408570188, -0.6969666117403229, -0.7729990905931439, -0.8502040419406404, -0.9283390589586046, -0.9964397983314542, -1.1232381953495887, -1.1989205710064659, -1.2789371988042382, -1.3604358626237936, -1.440597242936689], + "reference_angles_radians": [-0.21906271052192816, -0.28884457131740043, -0.37052366107858065, -0.4545569503357463, -0.5365773955295918, -0.6171645408570188, -0.6969666117403229, -0.7729990905931439, -0.8502040419406404, -0.9283390589586046, -0.9964397983314542, -1.1232381953495887, -1.1989205710064659, -1.2789371988042382, -1.3604358626237936, -1.440597242936689], + "response": { + "model": "concave_bernstein_v1", + "start_radians": -0.21906271052192816, + "end_radians": -1.440597242936689, + "coefficients": [0.0, 0.26723624915834737, 0.5344724983166947, 0.7871286394467959, 0.8935643197233979, 1.0], + "maximum_reference_error_pixels": 20.0 + }, "labels": ["0", "0.2", "0.4", "0.6", "0.8", "1", "1.2", "", "1.6", "", "2", "", "", "", "3.6", "4"], "minor_subdivisions": 2, "label_offset": 52.0 @@ -169,91 +183,25 @@ "guide_rgba": [161, 74, 58, 230], "guide_width": 3.2, "label_rgba": [45, 45, 42, 255], + "graph_clearance": 60.0, + "mask_gap": 14.0, + "curve_samples": 256, + "label_arc_fraction": 0.88, + "label_declutter_step": 3.0, + "label_box_padding": 2.0, "guides": [ - { - "label": "infinity", "display_label": "infinity", "swr": "infinity", - "visible_upper": [572.4055589579017, 456.7180848363287], - "registered_datum": [706.0, 880.0], - "hidden_lower": [775.4354660011804, 1100.0], - "quadratic_a": 0.0, "label_center": [600.0, 640.0] - }, - { - "label": "8", "display_label": "", "swr": 8.0, - "visible_upper": [643.7386837586928, 400.9725086642011], - "registered_datum": [777.0, 880.0], - "hidden_lower": [838.2021023915222, 1100.0], - "quadratic_a": 0.0, "label_center": [0.0, 0.0] - }, - { - "label": "4", "display_label": "4", "swr": 4.0, - "visible_upper": [729.1373769183177, 349.28646739305657], - "registered_datum": [814.5, 880.0], - "hidden_lower": [849.8859020434641, 1100.0], - "quadratic_a": 0.0000424263985491, "label_center": [720.0, 610.0] - }, - { - "label": "3", "display_label": "3", "swr": 3.0, - "visible_upper": [817.0220412910962, 375.4214323734527], - "registered_datum": [859.0, 880.0], - "hidden_lower": [877.3027015186148, 1100.0], - "quadratic_a": -0.0000126751055122, "label_center": [775.0, 600.0] - }, - { - "label": "2.5", "display_label": "2.5", "swr": 2.5, - "visible_upper": [880.5753026176498, 418.50208345200866], - "registered_datum": [892.0, 880.0], - "hidden_lower": [897.4462508583301, 1100.0], - "quadratic_a": -0.00000883735220486, "label_center": [835.0, 600.0] - }, - { - "label": "2", "display_label": "2", "swr": 2.0, - "visible_upper": [944.1031963228712, 471.62447595989926], - "registered_datum": [920.0, 880.0], - "hidden_lower": [907.0151297546643, 1100.0], - "quadratic_a": 0.0000851310500331, "label_center": [940.0, 625.0] - }, - { - "label": "1.7", "display_label": "1.7", "swr": 1.7, - "visible_upper": [1001.8471706723545, 531.0406361711822], - "registered_datum": [940.0, 880.0], - "hidden_lower": [901.0087048571288, 1100.0], - "quadratic_a": 0.0, "label_center": [1025.0, 655.0] - }, - { - "label": "1.5", "display_label": "1.5", "swr": 1.5, - "visible_upper": [1049.4649081096002, 591.025257178578], - "registered_datum": [957.5, 880.0], - "hidden_lower": [887.4859925937714, 1100.0], - "quadratic_a": -0.000102863228292, "label_center": [1095.0, 690.0] - }, - { - "label": "1.4", "display_label": "1.4", "swr": 1.4, - "visible_upper": [1086.976731505757, 648.2095714488747], - "registered_datum": [982.5, 880.0], - "hidden_lower": [883.3376615249372, 1100.0], - "quadratic_a": -0.000291448908069, "label_center": [1150.0, 720.0] - }, - { - "label": "1.3", "display_label": "1.3", "swr": 1.3, - "visible_upper": [1118.233544092191, 705.9785120853796], - "registered_datum": [1000.0, 880.0], - "hidden_lower": [850.5277709552518, 1100.0], - "quadratic_a": -0.0007925113422276841, "label_center": [1200.0, 750.0] - }, - { - "label": "1.2", "display_label": "1.2", "swr": 1.2, - "visible_upper": [1143.465448206219, 763.0567964294365], - "registered_datum": [1032.0, 880.0], - "hidden_lower": [822.3050553034373, 1100.0], - "quadratic_a": -0.00116458142175, "label_center": [1250.0, 800.0] - }, - { - "label": "1.1", "display_label": "1.1", "swr": 1.1, - "visible_upper": [1160.5087268449388, 810.3525905949436], - "registered_datum": [1063.0, 880.0], - "hidden_lower": [754.9925652779679, 1100.0], - "quadratic_a": -0.0015727756537, "label_center": [1290.0, 835.0] - } + { "label": "infinity", "display_label": "infinity", "swr": "infinity" }, + { "label": "8", "display_label": "8", "swr": 8.0 }, + { "label": "4", "display_label": "4", "swr": 4.0 }, + { "label": "3", "display_label": "3", "swr": 3.0 }, + { "label": "2.5", "display_label": "2.5", "swr": 2.5 }, + { "label": "2", "display_label": "2", "swr": 2.0 }, + { "label": "1.7", "display_label": "1.7", "swr": 1.7 }, + { "label": "1.5", "display_label": "1.5", "swr": 1.5 }, + { "label": "1.4", "display_label": "1.4", "swr": 1.4 }, + { "label": "1.3", "display_label": "1.3", "swr": 1.3 }, + { "label": "1.2", "display_label": "1.2", "swr": 1.2 }, + { "label": "1.1", "display_label": "1.1", "swr": 1.1 } ] }, "needles": { @@ -286,8 +234,8 @@ "active_swr": 1.5, "active_reflected_watts": 4.0, "range_multiplier": 10.0, - "intersection": [971.7280475161609, 835.2917160356153], + "intersection": [964.606018283696, 830.5562245313764], "guide": "1.5", - "maximum_guide_error": 2.0 + "maximum_guide_error": 0.5 } } diff --git a/resources/meterfaces/s-meter-v1.json b/resources/meterfaces/s-meter-v1.json new file mode 100644 index 000000000..82bad6e02 --- /dev/null +++ b/resources/meterfaces/s-meter-v1.json @@ -0,0 +1,125 @@ +{ + "format_version": 1, + "design_version": 5, + "sizing": { + "preferred": [280, 140], + "minimum": [200, 100], + "minimum_aspect_ratio": 1.75, + "maximum_aspect_ratio": 4.0 + }, + "arc": { + "center_x_width_factor": 0.5, + "radius_width_factor": 0.85, + "center_y_height_factor": 0.35, + "start_degrees": 55.0, + "end_degrees": 125.0, + "inner_gap_pixels": 6.0, + "line_width_pixels": 3.0 + }, + "tick_style": { + "start_offset_pixels": 2.0, + "end_offset_pixels": 14.0, + "label_offset_pixels": 26.0, + "line_width_pixels": 1.5, + "font_minimum_pixels": 10, + "font_height_factor": 0.1, + "bold": true + }, + "rx_scale": { + "minimum_dbm": -127.0, + "s9_dbm": -73.0, + "maximum_dbm": -13.0, + "db_per_s_unit": 6.0, + "s9_fraction": 0.6, + "ticks": [ + {"value": -121.0, "label": "1"}, + {"value": -109.0, "label": "3"}, + {"value": -97.0, "label": "5"}, + {"value": -85.0, "label": "7"}, + {"value": -73.0, "label": "9"}, + {"value": -53.0, "label": "+20"}, + {"value": -33.0, "label": "+40"} + ] + }, + "swr_scale": { + "minimum": 1.0, + "maximum": 3.0, + "has_warning": true, + "warning_start": 2.5, + "ticks": [ + {"value": 1.0, "label": "1"}, + {"value": 1.5, "label": "1.5"}, + {"value": 2.0, "label": "2"}, + {"value": 2.5, "label": "2.5"}, + {"value": 3.0, "label": "3"} + ] + }, + "level_scale": { + "minimum": -40.0, + "maximum": 5.0, + "has_warning": true, + "warning_start": 0.0, + "ticks": [ + {"value": -40.0, "label": "-40"}, + {"value": -30.0, "label": "-30"}, + {"value": -20.0, "label": "-20"}, + {"value": -10.0, "label": "-10"}, + {"value": 0.0, "label": "0"} + ] + }, + "compression_scale": { + "minimum": 0.0, + "maximum": 25.0, + "has_warning": false, + "warning_start": 0.0, + "ticks": [ + {"value": 0.0, "label": "0"}, + {"value": 5.0, "label": "-5"}, + {"value": 10.0, "label": "-10"}, + {"value": 15.0, "label": "-15"}, + {"value": 20.0, "label": "-20"}, + {"value": 25.0, "label": "-25"} + ] + }, + "power_tick_policies": [ + {"minimum_scale_watts": 2000.0, "tick_step_watts": 100, "label_step_watts": 500}, + {"minimum_scale_watts": 600.0, "tick_step_watts": 50, "label_step_watts": 100}, + {"minimum_scale_watts": 0.0, "tick_step_watts": 10, "label_step_watts": 40} + ], + "needle": { + "pivot_y_below_widget_pixels": 6.0, + "tip_extension_pixels": 14.0, + "line_width_pixels": 2.0, + "shadow_width_pixels": 3.0, + "shadow_offset": [1.0, 1.0] + }, + "pivot": { + "minimum_radius_pixels": 13.5, + "radius_width_factor": 0.0975, + "glow_radius_factor": 3.4, + "glow_middle_factor": 0.45, + "glow_center_alpha": 80, + "glow_middle_alpha": 28, + "rim_width_pixels": 1.0 + }, + "peak_marker": { + "radius_inset_pixels": 2.0, + "length_pixels": 6.0, + "half_width_pixels": 3.0, + "minimum_lead_db": 1.0 + }, + "peak_hold": { + "inner_radius_offset_pixels": -4.0, + "outer_radius_offset_pixels": 10.0, + "line_width_pixels": 2.0, + "visible_above_minimum_db": 1.0 + }, + "readout": { + "source_font_minimum_pixels": 9, + "source_font_height_divisor": 14.0, + "value_font_minimum_pixels": 13, + "value_font_height_divisor": 8.0, + "top_extra_pixels": 4, + "side_margin_pixels": 6 + } +} diff --git a/resources/resources.qrc b/resources/resources.qrc index 921806c17..315878258 100644 --- a/resources/resources.qrc +++ b/resources/resources.qrc @@ -20,7 +20,9 @@ themes/default-light.json + meterfaces/analog-meter-themes-v1.json meterfaces/cross-needle-v12.json + meterfaces/s-meter-v1.json help/getting-started.md diff --git a/src/core/AppSettings.cpp b/src/core/AppSettings.cpp index 143666e6b..9d12d12c3 100644 --- a/src/core/AppSettings.cpp +++ b/src/core/AppSettings.cpp @@ -49,6 +49,70 @@ QString machineBinding() constexpr char kIdentityConfigKey[] = "GuiClientIdentity"; +bool readSettingsFile(const QString& path, QMap& settings, + QMap& stationSettings, + QString& stationName, QString& error) +{ + QFile file(path); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { + error = file.errorString(); + return false; + } + + QMap parsedSettings; + QMap parsedStationSettings; + QString parsedStationName = QStringLiteral("AetherSDR"); + QString currentStation; + bool sawRoot = false; + QXmlStreamReader xml(&file); + + while (!xml.atEnd()) { + xml.readNext(); + + if (xml.isStartElement()) { + const QString tag = xml.name().toString(); + if (!sawRoot) { + if (tag != QStringLiteral("Settings")) { + xml.raiseError(QStringLiteral("root element is not Settings")); + } else { + sawRoot = true; + } + continue; + } + + if (tag == parsedStationName && currentStation.isEmpty()) { + currentStation = tag; + continue; + } + + const QString text = xml.readElementText(); + if (!currentStation.isEmpty()) { + parsedStationSettings.insert(tag, text); + } else { + parsedSettings.insert(tag, text); + if (tag == QStringLiteral("StationName")) { + parsedStationName = text; + } + } + } else if (xml.isEndElement() + && xml.name().toString() == currentStation) { + currentStation.clear(); + } + } + + if (xml.hasError() || !sawRoot) { + error = xml.hasError() ? xml.errorString() + : QStringLiteral("missing Settings root element"); + return false; + } + + settings = parsedSettings; + stationSettings = parsedStationSettings; + stationName = parsedStationName; + error.clear(); + return true; +} + } // namespace AppSettings::AppSettings() @@ -97,100 +161,100 @@ void AppSettings::migrateSettingsPath() void AppSettings::load() { - QFile file(m_filePath); - if (!file.exists()) { - // First launch or migration needed - migrateFromQSettings(); - return; - } - if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { - qWarning() << "AppSettings: cannot open" << m_filePath; - return; - } - + m_loadState = LoadState::Failed; + m_preserveRecoveryBackup = false; + m_loadedCount = 0; m_settings.clear(); m_stationSettings.clear(); - - QXmlStreamReader xml(&file); - QString currentStation; // non-empty when inside a station element - - while (!xml.atEnd()) { - xml.readNext(); - - if (xml.isStartElement()) { - const QString tag = xml.name().toString(); - - if (tag == "Settings") continue; // root element - - // Check if this is the station element - if (tag == m_stationName && currentStation.isEmpty()) { - currentStation = tag; - continue; + m_stationName = QStringLiteral("AetherSDR"); + + const QString tmpPath = m_filePath + QStringLiteral(".tmp"); + const QString bakPath = m_filePath + QStringLiteral(".bak"); + + if (!QFile::exists(m_filePath)) { + QString recoveryError; + + // A validated temp file is the newest intended state after a crash in + // the save window between live->backup and temp->live. Promote it only + // when the live path is absent, so a valid committed live file always + // remains authoritative. + if (QFile::exists(tmpPath) + && readSettingsFile(tmpPath, m_settings, m_stationSettings, + m_stationName, recoveryError)) { + if (QFile::rename(tmpPath, m_filePath)) { + QFile::setPermissions(m_filePath, + QFileDevice::ReadOwner | QFileDevice::WriteOwner); + m_preserveRecoveryBackup = QFile::exists(bakPath); + m_loadState = LoadState::ReadyToSave; + m_loadedCount = m_settings.size(); + qDebug() << "AppSettings: promoted interrupted commit with" + << m_settings.size() << "settings"; + return; } + qWarning() << "AppSettings: valid temporary settings could not be promoted"; + m_settings.clear(); + m_stationSettings.clear(); + m_stationName = QStringLiteral("AetherSDR"); + } else if (QFile::exists(tmpPath)) { + qWarning() << "AppSettings: temporary recovery file is corrupt:" + << recoveryError; + } - // Read the text content - const QString text = xml.readElementText(); + if (QFile::exists(bakPath) + && readSettingsFile(bakPath, m_settings, m_stationSettings, + m_stationName, recoveryError)) { + m_preserveRecoveryBackup = true; + m_loadState = LoadState::ReadyToSave; + m_loadedCount = m_settings.size(); + qDebug() << "AppSettings: recovered missing live profile from backup with" + << m_settings.size() << "settings"; + return; + } - if (!currentStation.isEmpty()) { - m_stationSettings.insert(tag, text); - } else { - m_settings.insert(tag, text); - // Track station name - if (tag == "StationName") - m_stationName = text; + // Recovery artifacts prove this is not a true first launch. If none is + // usable, stay failed so startup code cannot replace them with defaults. + if (QFile::exists(tmpPath) || QFile::exists(bakPath)) { + if (QFile::exists(bakPath)) { + qWarning() << "AppSettings: backup recovery file is unavailable or corrupt:" + << recoveryError; } - } else if (xml.isEndElement()) { - if (xml.name().toString() == m_stationName && !currentStation.isEmpty()) - currentStation.clear(); + m_settings.clear(); + m_stationSettings.clear(); + m_stationName = QStringLiteral("AetherSDR"); + return; } + + // No live or recovery artifacts: this is a genuine first launch or a + // legacy QSettings migration. + m_loadState = LoadState::ReadyToSave; + migrateFromQSettings(); + m_loadedCount = m_settings.size(); + return; } - if (xml.hasError()) { - qWarning() << "AppSettings: XML parse error:" << xml.errorString(); + QString error; + if (!readSettingsFile(m_filePath, m_settings, m_stationSettings, + m_stationName, error)) { + qWarning() << "AppSettings: cannot load" << m_filePath << ':' << error; - // Try to recover from backup if the main file is corrupt - const QString bakPath = m_filePath + ".bak"; - if (QFile::exists(bakPath) && m_settings.size() < 10) { - qWarning() << "AppSettings: main file corrupt, recovering from backup"; - file.close(); + if (!QFile::exists(bakPath) + || !readSettingsFile(bakPath, m_settings, m_stationSettings, + m_stationName, error)) { + if (QFile::exists(bakPath)) { + qWarning() << "AppSettings: backup also unavailable or corrupt:" << error; + } m_settings.clear(); m_stationSettings.clear(); - - QFile bakFile(bakPath); - if (bakFile.open(QIODevice::ReadOnly | QIODevice::Text)) { - QXmlStreamReader bakXml(&bakFile); - QString bakStation; - while (!bakXml.atEnd()) { - bakXml.readNext(); - if (bakXml.isStartElement()) { - const QString tag = bakXml.name().toString(); - if (tag == "Settings") continue; - if (tag == m_stationName && bakStation.isEmpty()) { - bakStation = tag; - continue; - } - const QString text = bakXml.readElementText(); - if (!bakStation.isEmpty()) - m_stationSettings.insert(tag, text); - else { - m_settings.insert(tag, text); - if (tag == "StationName") m_stationName = text; - } - } else if (bakXml.isEndElement()) { - if (bakXml.name().toString() == m_stationName && !bakStation.isEmpty()) - bakStation.clear(); - } - } - bakFile.close(); - if (!bakXml.hasError()) - qDebug() << "AppSettings: recovered" << m_settings.size() << "settings from backup"; - else - qWarning() << "AppSettings: backup also corrupt:" << bakXml.errorString(); - } + m_stationName = QStringLiteral("AetherSDR"); + return; } + + m_preserveRecoveryBackup = true; + qDebug() << "AppSettings: recovered" << m_settings.size() + << "settings from backup"; } - file.close(); + m_loadState = LoadState::ReadyToSave; m_loadedCount = m_settings.size(); qDebug() << "AppSettings: loaded" << m_settings.size() << "settings +" << m_stationSettings.size() << "station settings from" << m_filePath; @@ -207,6 +271,11 @@ void AppSettings::save() } } + if (m_loadState != LoadState::ReadyToSave) { + qWarning() << "AppSettings: refusing to save before a successful load"; + return; + } + // Guard: refuse to save if we'd lose more than half the settings. // This catches cases where the app crashes early or settings were // cleared from memory before save() runs. @@ -218,8 +287,8 @@ void AppSettings::save() // Atomic save: write to temp file, then rename over the original. // This prevents data loss if the app crashes or is killed mid-write. - const QString tmpPath = m_filePath + ".tmp"; - + const QString tmpPath = m_filePath + QStringLiteral(".tmp"); + const QString bakPath = m_filePath + QStringLiteral(".bak"); QFile file(tmpPath); if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { qWarning() << "AppSettings: cannot write" << tmpPath; @@ -266,41 +335,79 @@ void AppSettings::save() // If parsing fails, the file is corrupt; don't overwrite the original. { QFile check(tmpPath); - if (check.open(QIODevice::ReadOnly | QIODevice::Text)) { - QXmlStreamReader validator(&check); - while (!validator.atEnd()) validator.readNext(); - check.close(); - if (validator.hasError()) { - qWarning() << "AppSettings: temp file failed validation:" - << validator.errorString() << "— NOT saving"; - QFile::remove(tmpPath); - return; - } + if (!check.open(QIODevice::ReadOnly | QIODevice::Text)) { + qWarning() << "AppSettings: cannot reopen temp file for validation — NOT saving"; + QFile::remove(tmpPath); + return; + } + QXmlStreamReader validator(&check); + while (!validator.atEnd()) { + validator.readNext(); + } + check.close(); + if (validator.hasError()) { + qWarning() << "AppSettings: temp file failed validation:" + << validator.errorString() << "— NOT saving"; + QFile::remove(tmpPath); + return; } } // Atomic rename: on Linux/macOS this is a single inode swap. - // On Windows, QFile::rename fails if the target exists, so remove first. + // On Windows, QFile::rename fails if the target exists, so rotate first. + QString displacedCorruptPath; + bool rotatedLiveToBackup = false; if (QFile::exists(m_filePath)) { - // Keep a backup in case something goes wrong - const QString bakPath = m_filePath + ".bak"; - QFile::remove(bakPath); - QFile::rename(m_filePath, bakPath); - // Tighten the backup the same way as the live file — see below. - QFile::setPermissions(bakPath, - QFileDevice::ReadOwner | QFileDevice::WriteOwner); + if (m_preserveRecoveryBackup && QFile::exists(bakPath)) { + // Keep the known-good recovery source through the first save after + // recovery. Move the existing live file aside only until the + // replacement has been installed. + displacedCorruptPath = m_filePath + QStringLiteral(".corrupt"); + QFile::remove(displacedCorruptPath); + if (!QFile::rename(m_filePath, displacedCorruptPath)) { + qWarning() << "AppSettings: cannot move corrupt live file aside"; + QFile::remove(tmpPath); + return; + } + } else { + QFile::remove(bakPath); + if (!QFile::rename(m_filePath, bakPath)) { + qWarning() << "AppSettings: cannot rotate live settings to backup"; + QFile::remove(tmpPath); + return; + } + rotatedLiveToBackup = true; + QFile::setPermissions(bakPath, + QFileDevice::ReadOwner | QFileDevice::WriteOwner); + } } if (!QFile::rename(tmpPath, m_filePath)) { qWarning() << "AppSettings: atomic rename failed from" << tmpPath << "to" << m_filePath; + if (!displacedCorruptPath.isEmpty()) { + QFile::rename(displacedCorruptPath, m_filePath); + } else if (rotatedLiveToBackup && !QFile::exists(m_filePath)) { + if (!QFile::copy(bakPath, m_filePath)) { + qWarning() << "AppSettings: could not restore live settings from backup"; + } else { + QFile::setPermissions(m_filePath, + QFileDevice::ReadOwner | QFileDevice::WriteOwner); + } + } return; } + if (!displacedCorruptPath.isEmpty()) { + QFile::remove(displacedCorruptPath); + } + // Restrict permissions to owner read/write (mode 0600). Default umask // leaves these XML files world-readable, exposing MQTT broker creds, // SmartLink email, radio nicknames, etc. Matches AsyncLogWriter // precedent. See GHSA-mmqp-cm4w-cvpp (L5). QFile::setPermissions(m_filePath, QFileDevice::ReadOwner | QFileDevice::WriteOwner); + m_loadedCount = m_settings.size(); + m_preserveRecoveryBackup = false; } void AppSettings::reset() @@ -310,6 +417,8 @@ void AppSettings::reset() m_stationSettings.clear(); m_stationName = "AetherSDR"; m_loadedCount = 0; + m_loadState = LoadState::NotAttempted; + m_preserveRecoveryBackup = false; m_persistentGuiClientId.clear(); m_effectiveGuiClientId.clear(); m_effectiveStationName.clear(); @@ -549,8 +658,17 @@ void AppSettings::recordPersistentGuiClientIdReply(const QString& clientId) void AppSettings::migrateFromQSettings() { - QSettings old("AetherSDR", "AetherSDR"); - const QStringList keys = old.allKeys(); + std::unique_ptr old; + if (QStandardPaths::isTestModeEnabled()) { + const QString isolatedLegacyPath = + QStandardPaths::writableLocation(QStandardPaths::GenericConfigLocation) + + QStringLiteral("/AetherSDR/legacy-qsettings.ini"); + old = std::make_unique(isolatedLegacyPath, QSettings::IniFormat); + } else { + old = std::make_unique(QStringLiteral("AetherSDR"), + QStringLiteral("AetherSDR")); + } + const QStringList keys = old->allKeys(); if (keys.isEmpty()) { // No old settings — first launch. Set defaults. @@ -576,37 +694,37 @@ void AppSettings::migrateFromQSettings() // Map old QSettings keys to new XML keys // MainWindow - if (old.contains("lastRadioSerial")) - setValue("LastConnectedRadioSerial", old.value("lastRadioSerial").toString()); - if (old.contains("geometry")) - setValue("MainWindowGeometry", old.value("geometry").toByteArray().toBase64()); - if (old.contains("windowState")) - setValue("MainWindowState", old.value("windowState").toByteArray().toBase64()); - if (old.contains("splitterState")) - setValue("SplitterState", old.value("splitterState").toByteArray().toBase64()); + if (old->contains("lastRadioSerial")) + setValue("LastConnectedRadioSerial", old->value("lastRadioSerial").toString()); + if (old->contains("geometry")) + setValue("MainWindowGeometry", old->value("geometry").toByteArray().toBase64()); + if (old->contains("windowState")) + setValue("MainWindowState", old->value("windowState").toByteArray().toBase64()); + if (old->contains("splitterState")) + setValue("SplitterState", old->value("splitterState").toByteArray().toBase64()); // Spectrum - if (old.contains("spectrum/splitRatio")) - setValue("SpectrumSplitRatio", old.value("spectrum/splitRatio").toString()); + if (old->contains("spectrum/splitRatio")) + setValue("SpectrumSplitRatio", old->value("spectrum/splitRatio").toString()); // Spots - if (old.contains("spots/enabled")) - setValue("IsSpotsEnabled", old.value("spots/enabled").toBool() ? "True" : "False"); - if (old.contains("spots/levels")) - setValue("SpotsMaxLevel", old.value("spots/levels").toString()); - if (old.contains("spots/position")) - setValue("SpotsStartingHeightPercentage", old.value("spots/position").toString()); - if (old.contains("spots/fontSize")) - setValue("SpotFontSize", old.value("spots/fontSize").toString()); - if (old.contains("spots/overrideColors")) + if (old->contains("spots/enabled")) + setValue("IsSpotsEnabled", old->value("spots/enabled").toBool() ? "True" : "False"); + if (old->contains("spots/levels")) + setValue("SpotsMaxLevel", old->value("spots/levels").toString()); + if (old->contains("spots/position")) + setValue("SpotsStartingHeightPercentage", old->value("spots/position").toString()); + if (old->contains("spots/fontSize")) + setValue("SpotFontSize", old->value("spots/fontSize").toString()); + if (old->contains("spots/overrideColors")) setValue("IsSpotsOverrideColorsEnabled", - old.value("spots/overrideColors").toBool() ? "True" : "False"); - if (old.contains("spots/overrideBg")) + old->value("spots/overrideColors").toBool() ? "True" : "False"); + if (old->contains("spots/overrideBg")) setValue("IsSpotsOverrideBackgroundColorsEnabled", - old.value("spots/overrideBg").toBool() ? "True" : "False"); - if (old.contains("spots/overrideBgAuto")) + old->value("spots/overrideBg").toBool() ? "True" : "False"); + if (old->contains("spots/overrideBgAuto")) setValue("IsSpotsOverrideToAutoBackgroundColorEnabled", - old.value("spots/overrideBgAuto").toBool() ? "True" : "False"); + old->value("spots/overrideBgAuto").toBool() ? "True" : "False"); // Set defaults for new keys setValue("ApplicationVersion", QCoreApplication::applicationVersion()); diff --git a/src/core/AppSettings.h b/src/core/AppSettings.h index 55e202d8c..1d4612fe0 100644 --- a/src/core/AppSettings.h +++ b/src/core/AppSettings.h @@ -71,6 +71,12 @@ class AppSettings { void migrateFromQSettings(); private: + enum class LoadState { + NotAttempted, + ReadyToSave, + Failed, + }; + AppSettings(); ~AppSettings(); AppSettings(const AppSettings&) = delete; @@ -85,6 +91,8 @@ class AppSettings { QMap m_stationSettings; // per-station key=value QString m_stationName{"AetherSDR"}; int m_loadedCount{0}; // settings count at load time (guard against truncated saves) + LoadState m_loadState{LoadState::NotAttempted}; + bool m_preserveRecoveryBackup{false}; std::unique_ptr m_guiClientLock; QString m_persistentGuiClientId; QString m_effectiveGuiClientId; diff --git a/src/core/AutomationServer.cpp b/src/core/AutomationServer.cpp index 867756671..6ba1ce2a1 100644 --- a/src/core/AutomationServer.cpp +++ b/src/core/AutomationServer.cpp @@ -440,11 +440,27 @@ QJsonObject describeWidget(const QWidget* w) } } - // The PWR applet's custom-painted cross-needle meter publishes its live - // mechanics as dynamic properties. Surface them generically so bridge - // validation can prove the two calibrated movements and SWR intersection - // without coupling the core automation server to a gui/ header. + // The custom-painted analog meters publish their live mechanics as dynamic + // properties. Surface them generically so bridge validation can prove the + // standard meter's native SWR filtering and the PWR applet's two calibrated + // movements without coupling the core automation server to gui/ headers. { + const QVariant txSwrSource = w->property("txSwrSource"); + if (txSwrSource.isValid()) { + o[QStringLiteral("txSwrSource")] = txSwrSource.toString(); + o[QStringLiteral("txSwr")] = w->property("txSwr").toDouble(); + o[QStringLiteral("txSwrRaw")] = w->property("txSwrRaw").toDouble(); + o[QStringLiteral("txSwrForwardWatts")] = + w->property("txSwrForwardWatts").toDouble(); + o[QStringLiteral("txSwrPowerEnvelopeWatts")] = + w->property("txSwrPowerEnvelopeWatts").toDouble(); + o[QStringLiteral("txSwrMinimumForwardWatts")] = + w->property("txSwrMinimumForwardWatts").toDouble(); + o[QStringLiteral("txSwrHeld")] = w->property("txSwrHeld").toBool(); + o[QStringLiteral("txMode")] = w->property("txMode").toString(); + o[QStringLiteral("transmitting")] = + w->property("transmitting").toBool(); + } const QVariant meterStyle = w->property("meterStyle"); if (meterStyle.isValid()) { o[QStringLiteral("meterStyle")] = meterStyle.toString(); @@ -465,6 +481,8 @@ QJsonObject describeWidget(const QWidget* w) o[QStringLiteral("swr")] = w->property("swr").toDouble(); o[QStringLiteral("rangeMultiplier")] = w->property("rangeMultiplier").toDouble(); + o[QStringLiteral("rangeLegendVisible")] = + w->property("rangeLegendVisible").toBool(); o[QStringLiteral("transmitting")] = w->property("transmitting").toBool(); o[QStringLiteral("effectiveActive")] = diff --git a/src/gui/AnalogMeterFaceTheme.cpp b/src/gui/AnalogMeterFaceTheme.cpp new file mode 100644 index 000000000..3d26b9732 --- /dev/null +++ b/src/gui/AnalogMeterFaceTheme.cpp @@ -0,0 +1,692 @@ +#include "AnalogMeterFaceTheme.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace AetherSDR { + +namespace { + +constexpr int kFormatVersion = 1; + +bool readFiniteDouble(const QJsonObject& object, const QString& key, + double& output, QString& error) +{ + const QJsonValue value = object.value(key); + if (!value.isDouble() || !std::isfinite(value.toDouble())) { + error = QStringLiteral("%1 must be a finite number").arg(key); + return false; + } + output = value.toDouble(); + return true; +} + +bool readPoint(const QJsonObject& object, const QString& key, + QPointF& output, QString& error) +{ + const QJsonValue value = object.value(key); + if (!value.isArray()) { + error = QStringLiteral("%1 must be a two-number array").arg(key); + return false; + } + const QJsonArray array = value.toArray(); + if (array.size() != 2 || !array[0].isDouble() || !array[1].isDouble() + || !std::isfinite(array[0].toDouble()) || !std::isfinite(array[1].toDouble())) { + error = QStringLiteral("%1 must be a two-number array").arg(key); + return false; + } + output = QPointF(array[0].toDouble(), array[1].toDouble()); + return true; +} + +bool readRect(const QJsonObject& object, const QString& key, + QRectF& output, QString& error) +{ + const QJsonValue value = object.value(key); + if (!value.isArray()) { + error = QStringLiteral("%1 must be a four-number array").arg(key); + return false; + } + const QJsonArray array = value.toArray(); + if (array.size() != 4) { + error = QStringLiteral("%1 must be a four-number array").arg(key); + return false; + } + for (const QJsonValue& entry : array) { + if (!entry.isDouble() || !std::isfinite(entry.toDouble())) { + error = QStringLiteral("%1 must be a four-number array").arg(key); + return false; + } + } + output = QRectF(array[0].toDouble(), array[1].toDouble(), + array[2].toDouble(), array[3].toDouble()); + return true; +} + +bool readColor(const QJsonObject& object, const QString& key, + QColor& output, QString& error) +{ + const QJsonValue value = object.value(key); + if (!value.isArray()) { + error = QStringLiteral("%1 must be an RGBA array").arg(key); + return false; + } + const QJsonArray array = value.toArray(); + if (array.size() != 4) { + error = QStringLiteral("%1 must be an RGBA array").arg(key); + return false; + } + int channels[4]{}; + for (int index = 0; index < 4; ++index) { + if (!array[index].isDouble()) { + error = QStringLiteral("%1 must be an RGBA array").arg(key); + return false; + } + channels[index] = array[index].toInt(-1); + if (channels[index] < 0 || channels[index] > 255) { + error = QStringLiteral("%1 contains an invalid channel").arg(key); + return false; + } + } + output = QColor(channels[0], channels[1], channels[2], channels[3]); + return true; +} + +bool readPalette(const QJsonObject& object, + AnalogMeterFaceThemeCatalog::Palette& palette, + QString& error) +{ + return readColor(object, QStringLiteral("ribbon_rgba"), palette.ribbon, error) + && readColor(object, QStringLiteral("scale_outer_rgba"), palette.scaleOuter, error) + && readColor(object, QStringLiteral("scale_separator_rgba"), palette.scaleSeparator, error) + && readColor(object, QStringLiteral("scale_calibration_rgba"), palette.scaleCalibration, error) + && readColor(object, QStringLiteral("scale_inner_rgba"), palette.scaleInner, error) + && readColor(object, QStringLiteral("major_tick_rgba"), palette.majorTick, error) + && readColor(object, QStringLiteral("minor_tick_rgba"), palette.minorTick, error) + && readColor(object, QStringLiteral("text_rgba"), palette.text, error) + && readColor(object, QStringLiteral("secondary_text_rgba"), palette.secondaryText, error) + && readColor(object, QStringLiteral("swr_guide_rgba"), palette.swrGuide, error) + && readColor(object, QStringLiteral("swr_label_rgba"), palette.swrLabel, error) + && readColor(object, QStringLiteral("needle_rgba"), palette.needle, error) + && readColor(object, QStringLiteral("needle_edge_rgba"), palette.needleEdge, error) + && readColor(object, QStringLiteral("needle_highlight_rgba"), palette.needleHighlight, error) + && readColor(object, QStringLiteral("needle_shadow_rgba"), palette.needleShadow, error) + && readColor(object, QStringLiteral("needle_soft_shadow_rgba"), palette.needleSoftShadow, error) + && readColor(object, QStringLiteral("mask_fill_rgba"), palette.maskFill, error) + && readColor(object, QStringLiteral("mask_edge_rgba"), palette.maskEdge, error) + && readColor(object, QStringLiteral("mask_text_rgba"), palette.maskText, error); +} + +const QImage& paperGrainTexture() +{ + static const QImage texture = []() { + QImage image(180, 120, QImage::Format_RGB32); + quint32 state = 0x6d2b79f5U; + const auto nextNoise = [&state]() { + state ^= state << 13U; + state ^= state >> 17U; + state ^= state << 5U; + return state; + }; + for (int y = 0; y < image.height(); ++y) { + const int rowBias = static_cast((nextNoise() >> 28U) & 0x0fU) - 8; + int horizontalFibre = 0; + QRgb* line = reinterpret_cast(image.scanLine(y)); + for (int x = 0; x < image.width(); ++x) { + const quint32 sample = nextNoise(); + const int fine = static_cast((sample >> 26U) & 0x3fU) - 32; + horizontalFibre = (7 * horizontalFibre + fine) / 8; + const int fleck = (sample & 0xffU) < 6U + ? (((sample >> 8U) & 1U) == 0U ? -34 : 34) + : 0; + const int level = std::clamp( + 128 + rowBias + fine + 2 * horizontalFibre + fleck, 58, 198); + line[x] = qRgb(level, level, level); + } + } + return image; + }(); + return texture; +} + +void drawPaperGrain(QPainter& painter, const QRectF& face, double opacity) +{ + if (opacity <= 0.0) { + return; + } + painter.save(); + painter.setOpacity(opacity); + painter.setCompositionMode(QPainter::CompositionMode_Overlay); + painter.setRenderHint(QPainter::SmoothPixmapTransform, true); + painter.drawImage(face, paperGrainTexture()); + painter.restore(); +} + +bool validStop(double stop) +{ + return std::isfinite(stop) && stop > 0.0 && stop < 1.0; +} + +} // namespace + +QString analogMeterFaceThemeId(AnalogMeterFaceTheme theme) +{ + switch (theme) { + case AnalogMeterFaceTheme::AetherDefault: + return QStringLiteral("aether-default"); + case AnalogMeterFaceTheme::ClassicWarm: + return QStringLiteral("classic-warm"); + case AnalogMeterFaceTheme::DarkRoomUplight: + return QStringLiteral("dark-room-uplight"); + case AnalogMeterFaceTheme::GraphiteDark: + return QStringLiteral("graphite-dark"); + } + return QStringLiteral("aether-default"); +} + +AnalogMeterFaceTheme analogMeterFaceThemeFromId( + const QString& id, AnalogMeterFaceTheme fallback) +{ + if (id == QStringLiteral("aether-default")) { + return AnalogMeterFaceTheme::AetherDefault; + } + if (id == QStringLiteral("classic-warm")) { + return AnalogMeterFaceTheme::ClassicWarm; + } + if (id == QStringLiteral("dark-room-uplight")) { + return AnalogMeterFaceTheme::DarkRoomUplight; + } + if (id == QStringLiteral("graphite-dark")) { + return AnalogMeterFaceTheme::GraphiteDark; + } + return fallback; +} + +AnalogMeterFaceThemeCatalog AnalogMeterFaceThemeCatalog::loadResource(QString* error) +{ + QFile resource(QStringLiteral(":/meterfaces/analog-meter-themes-v1.json")); + if (!resource.open(QIODevice::ReadOnly)) { + if (error) { + *error = QStringLiteral("cannot open shared analog meter theme resource"); + } + return {}; + } + return load(resource, error); +} + +AnalogMeterFaceThemeCatalog AnalogMeterFaceThemeCatalog::load( + QIODevice& device, QString* error) +{ + if (error) { + error->clear(); + } + QJsonParseError parseError; + const QJsonDocument document = QJsonDocument::fromJson(device.readAll(), &parseError); + if (parseError.error != QJsonParseError::NoError || !document.isObject()) { + if (error) { + *error = parseError.error != QJsonParseError::NoError + ? parseError.errorString() + : QStringLiteral("shared analog meter theme root is not an object"); + } + return {}; + } + + const QJsonObject root = document.object(); + AnalogMeterFaceThemeCatalog catalog = fallback(); + QString readError; + if (!root.value(QStringLiteral("format_version")).isDouble() + || root.value(QStringLiteral("format_version")).toInt() != kFormatVersion) { + readError = QStringLiteral("unsupported shared analog meter theme version"); + } + catalog.formatVersion = root.value(QStringLiteral("format_version")).toInt(); + if (readError.isEmpty() + && !readRect(root, QStringLiteral("reference_face"), catalog.referenceFace, readError)) { + } + + const QJsonObject mask = root.value(QStringLiteral("lower_mask")).toObject(); + if (readError.isEmpty() && mask.isEmpty()) { + readError = QStringLiteral("lower_mask must be an object"); + } + if (readError.isEmpty()) { + const QJsonValue boundaryValue = mask.value(QStringLiteral("boundary_normalized")); + if (!boundaryValue.isArray()) { + readError = QStringLiteral("lower mask boundary must be an array"); + } else { + QVector boundary; + for (const QJsonValue& pointValue : boundaryValue.toArray()) { + if (!pointValue.isArray()) { + readError = QStringLiteral("lower mask point must be a two-number array"); + break; + } + const QJsonArray point = pointValue.toArray(); + if (point.size() != 2 || !point[0].isDouble() || !point[1].isDouble() + || !std::isfinite(point[0].toDouble()) + || !std::isfinite(point[1].toDouble())) { + readError = QStringLiteral("lower mask point must be a two-number array"); + break; + } + boundary.push_back(QPointF(point[0].toDouble(), point[1].toDouble())); + } + if (readError.isEmpty()) { + catalog.normalizedMaskBoundary = boundary; + } + } + } + if (readError.isEmpty()) { + readFiniteDouble(mask, QStringLiteral("bottom_normalized"), + catalog.normalizedMaskBottom, readError); + } + + const QJsonObject classic = root.value(QStringLiteral("classic_gradient")).toObject(); + if (readError.isEmpty() && classic.isEmpty()) { + readError = QStringLiteral("classic_gradient must be an object"); + } + if (readError.isEmpty()) { + FaceGradient& gradient = catalog.classicGradient; + readColor(classic, QStringLiteral("top_rgba"), gradient.top, readError) + && readColor(classic, QStringLiteral("middle_rgba"), gradient.middle, readError) + && readColor(classic, QStringLiteral("bottom_rgba"), gradient.bottom, readError) + && readFiniteDouble(classic, QStringLiteral("middle_stop"), gradient.middleStop, readError) + && readPoint(classic, QStringLiteral("glow_center"), gradient.glowCenter, readError) + && readFiniteDouble(classic, QStringLiteral("glow_radius"), gradient.glowRadius, readError) + && readColor(classic, QStringLiteral("glow_inner_rgba"), gradient.glowInner, readError) + && readColor(classic, QStringLiteral("glow_outer_rgba"), gradient.glowOuter, readError) + && readPoint(classic, QStringLiteral("vignette_center"), gradient.vignetteCenter, readError) + && readFiniteDouble(classic, QStringLiteral("vignette_radius"), gradient.vignetteRadius, readError) + && readFiniteDouble(classic, QStringLiteral("vignette_clear_stop"), gradient.vignetteClearStop, readError) + && readColor(classic, QStringLiteral("vignette_edge_rgba"), gradient.vignetteEdge, readError) + && readFiniteDouble(classic, QStringLiteral("paper_grain_opacity"), gradient.paperGrainOpacity, readError); + } + + const QJsonObject uplight = root.value(QStringLiteral("uplight_gradient")).toObject(); + if (readError.isEmpty() && uplight.isEmpty()) { + readError = QStringLiteral("uplight_gradient must be an object"); + } + if (readError.isEmpty()) { + UplightGradient& gradient = catalog.uplightGradient; + readColor(uplight, QStringLiteral("top_rgba"), gradient.top, readError) + && readColor(uplight, QStringLiteral("middle_rgba"), gradient.middle, readError) + && readColor(uplight, QStringLiteral("bottom_rgba"), gradient.bottom, readError) + && readFiniteDouble(uplight, QStringLiteral("middle_stop"), gradient.middleStop, readError) + && readPoint(uplight, QStringLiteral("halo_center"), gradient.haloCenter, readError) + && readFiniteDouble(uplight, QStringLiteral("halo_radius"), gradient.haloRadius, readError) + && readColor(uplight, QStringLiteral("halo_inner_rgba"), gradient.haloInner, readError) + && readColor(uplight, QStringLiteral("halo_middle_rgba"), gradient.haloMiddle, readError) + && readFiniteDouble(uplight, QStringLiteral("halo_middle_stop"), gradient.haloMiddleStop, readError) + && readColor(uplight, QStringLiteral("halo_shoulder_rgba"), gradient.haloShoulder, readError) + && readFiniteDouble(uplight, QStringLiteral("halo_shoulder_stop"), gradient.haloShoulderStop, readError) + && readColor(uplight, QStringLiteral("halo_outer_rgba"), gradient.haloOuter, readError) + && readPoint(uplight, QStringLiteral("hotspot_center"), gradient.hotspotCenter, readError) + && readFiniteDouble(uplight, QStringLiteral("hotspot_radius"), gradient.hotspotRadius, readError) + && readColor(uplight, QStringLiteral("hotspot_inner_rgba"), gradient.hotspotInner, readError) + && readColor(uplight, QStringLiteral("hotspot_middle_rgba"), gradient.hotspotMiddle, readError) + && readFiniteDouble(uplight, QStringLiteral("hotspot_middle_stop"), gradient.hotspotMiddleStop, readError) + && readColor(uplight, QStringLiteral("hotspot_outer_rgba"), gradient.hotspotOuter, readError) + && readPoint(uplight, QStringLiteral("bloom_center"), gradient.bloomCenter, readError) + && readFiniteDouble(uplight, QStringLiteral("bloom_radius"), gradient.bloomRadius, readError) + && readColor(uplight, QStringLiteral("bloom_inner_rgba"), gradient.bloomInner, readError) + && readColor(uplight, QStringLiteral("bloom_middle_rgba"), gradient.bloomMiddle, readError) + && readFiniteDouble(uplight, QStringLiteral("bloom_middle_stop"), gradient.bloomMiddleStop, readError) + && readColor(uplight, QStringLiteral("bloom_outer_rgba"), gradient.bloomOuter, readError) + && readPoint(uplight, QStringLiteral("vignette_center"), gradient.vignetteCenter, readError) + && readFiniteDouble(uplight, QStringLiteral("vignette_radius"), gradient.vignetteRadius, readError) + && readFiniteDouble(uplight, QStringLiteral("vignette_clear_stop"), gradient.vignetteClearStop, readError) + && readColor(uplight, QStringLiteral("vignette_edge_rgba"), gradient.vignetteEdge, readError) + && readFiniteDouble(uplight, QStringLiteral("paper_grain_opacity"), gradient.paperGrainOpacity, readError); + } + + const QJsonObject dark = root.value(QStringLiteral("dark_gradient")).toObject(); + if (readError.isEmpty() && dark.isEmpty()) { + readError = QStringLiteral("dark_gradient must be an object"); + } + if (readError.isEmpty()) { + DarkGradient& gradient = catalog.darkGradient; + readColor(dark, QStringLiteral("top_rgba"), gradient.top, readError) + && readColor(dark, QStringLiteral("middle_rgba"), gradient.middle, readError) + && readColor(dark, QStringLiteral("bottom_rgba"), gradient.bottom, readError) + && readFiniteDouble(dark, QStringLiteral("middle_stop"), gradient.middleStop, readError) + && readPoint(dark, QStringLiteral("ambient_center"), gradient.ambientCenter, readError) + && readFiniteDouble(dark, QStringLiteral("ambient_radius"), gradient.ambientRadius, readError) + && readColor(dark, QStringLiteral("ambient_inner_rgba"), gradient.ambientInner, readError) + && readColor(dark, QStringLiteral("ambient_outer_rgba"), gradient.ambientOuter, readError) + && readPoint(dark, QStringLiteral("glow_center"), gradient.glowCenter, readError) + && readFiniteDouble(dark, QStringLiteral("glow_radius"), gradient.glowRadius, readError) + && readColor(dark, QStringLiteral("glow_inner_rgba"), gradient.glowInner, readError) + && readColor(dark, QStringLiteral("glow_middle_rgba"), gradient.glowMiddle, readError) + && readFiniteDouble(dark, QStringLiteral("glow_middle_stop"), gradient.glowMiddleStop, readError) + && readColor(dark, QStringLiteral("glow_outer_rgba"), gradient.glowOuter, readError) + && readPoint(dark, QStringLiteral("vignette_center"), gradient.vignetteCenter, readError) + && readFiniteDouble(dark, QStringLiteral("vignette_radius"), gradient.vignetteRadius, readError) + && readFiniteDouble(dark, QStringLiteral("vignette_clear_stop"), gradient.vignetteClearStop, readError) + && readColor(dark, QStringLiteral("vignette_edge_rgba"), gradient.vignetteEdge, readError) + && readFiniteDouble(dark, QStringLiteral("paper_grain_opacity"), gradient.paperGrainOpacity, readError); + } + + const QJsonObject palettes = root.value(QStringLiteral("palettes")).toObject(); + if (readError.isEmpty() && palettes.isEmpty()) { + readError = QStringLiteral("palettes must be an object"); + } + if (readError.isEmpty()) { + const QJsonObject classicPalette = palettes.value(QStringLiteral("classic-warm")).toObject(); + const QJsonObject uplightPalette = palettes.value(QStringLiteral("dark-room-uplight")).toObject(); + const QJsonObject darkPalette = palettes.value(QStringLiteral("graphite-dark")).toObject(); + if (classicPalette.isEmpty() || uplightPalette.isEmpty() || darkPalette.isEmpty()) { + readError = QStringLiteral("all physical meter palettes are required"); + } else { + readPalette(classicPalette, catalog.classicPalette, readError) + && readPalette(uplightPalette, catalog.uplightPalette, readError) + && readPalette(darkPalette, catalog.darkPalette, readError); + } + } + + QString validationError; + if (readError.isEmpty() && !catalog.isValid(&validationError)) { + readError = validationError; + } + if (!readError.isEmpty()) { + if (error) { + *error = readError; + } + return {}; + } + return catalog; +} + +AnalogMeterFaceThemeCatalog AnalogMeterFaceThemeCatalog::fallback() +{ + AnalogMeterFaceThemeCatalog catalog; + catalog.formatVersion = kFormatVersion; + catalog.referenceFace = QRectF(26.0, 26.0, 1448.0, 948.0); + catalog.normalizedMaskBoundary = { + {0.0179558011, 0.9261603376}, {0.1546961326, 0.9261603376}, + {0.3273480663, 0.9219409283}, {0.4309392265, 0.9008438819}, + {0.5, 0.8924050633}, {0.5690607735, 0.9008438819}, + {0.6726519337, 0.9219409283}, {0.8453038674, 0.9261603376}, + {0.9820441989, 0.9261603376}}; + catalog.normalizedMaskBottom = 1.0; + + catalog.classicGradient = { + {232, 216, 183}, {250, 242, 221}, {222, 205, 172}, 0.50, + {750.0, 400.0}, 900.0, {255, 250, 235, 205}, {255, 250, 235, 0}, + {750.0, 440.0}, 930.0, 0.62, {106, 97, 76, 65}, 0.0}; + catalog.uplightGradient = { + {142, 110, 73}, {174, 126, 70}, {169, 119, 65}, 0.60, + {750.0, 920.0}, 930.0, {255, 181, 83, 180}, {242, 160, 72, 170}, + 0.52, {218, 143, 68, 40}, 0.72, {211, 137, 69, 0}, + {750.0, 930.0}, 470.0, {255, 245, 110, 220}, {255, 195, 65, 145}, + 0.48, {255, 146, 44, 0}, {750.0, 940.0}, 330.0, + {255, 246, 95, 225}, {255, 215, 65, 90}, 0.52, {255, 220, 130, 0}, + {750.0, 650.0}, 890.0, 0.34, {9, 10, 12, 120}, 0.100}; + catalog.darkGradient = { + {20, 22, 23}, {27, 28, 28}, {25, 24, 23}, 0.58, + {750.0, 410.0}, 900.0, {68, 66, 61, 55}, {40, 40, 38, 0}, + {750.0, 940.0}, 720.0, {190, 92, 35, 100}, {112, 59, 34, 38}, + 0.58, {80, 43, 30, 0}, {750.0, 500.0}, 940.0, 0.42, + {0, 0, 0, 120}, 0.220}; + + const Palette physicalPalette{ + {224, 212, 187, 225}, {30, 36, 38}, {246, 237, 216}, + {126, 62, 54, 235}, {48, 65, 91}, {35, 42, 46}, + {55, 72, 91, 230}, {35, 42, 46}, {54, 61, 66}, + {161, 74, 58, 230}, {45, 45, 42}, {18, 22, 25}, + {2, 4, 5, 210}, {132, 132, 124, 175}, {0, 0, 0, 80}, + {0, 0, 0, 28}, {34, 40, 47}, {87, 99, 110}, {234, 238, 241}}; + catalog.classicPalette = physicalPalette; + catalog.uplightPalette = physicalPalette; + catalog.uplightPalette.scaleSeparator = QColor(117, 75, 48, 150); + catalog.darkPalette = { + {44, 43, 40, 225}, {202, 188, 159}, {126, 95, 73, 225}, + {119, 71, 59, 235}, {59, 84, 105}, {205, 191, 162}, + {151, 139, 117, 230}, {205, 190, 158}, {179, 162, 132}, + {139, 77, 58, 225}, {205, 190, 158}, {202, 197, 183}, + {82, 78, 69, 220}, {240, 234, 216}, {0, 0, 0, 105}, + {0, 0, 0, 36}, {20, 23, 26}, {64, 69, 74}, {219, 207, 181}}; + return catalog; +} + +bool AnalogMeterFaceThemeCatalog::isValid(QString* error) const +{ + const auto fail = [error](const QString& message) { + if (error) { + *error = message; + } + return false; + }; + if (error) { + error->clear(); + } + if (formatVersion != kFormatVersion) { + return fail(QStringLiteral("unsupported shared analog meter theme version")); + } + if (!(referenceFace.width() > 0.0) || !(referenceFace.height() > 0.0)) { + return fail(QStringLiteral("shared analog meter reference face is invalid")); + } + if (normalizedMaskBoundary.size() != 9 + || !(normalizedMaskBottom > 0.0 && normalizedMaskBottom <= 1.0)) { + return fail(QStringLiteral("shared analog meter lower mask is invalid")); + } + double previousX = -1.0; + for (const QPointF& point : normalizedMaskBoundary) { + if (!std::isfinite(point.x()) || !std::isfinite(point.y()) + || point.x() < 0.0 || point.x() > 1.0 + || point.y() < 0.0 || point.y() > normalizedMaskBottom + || point.x() <= previousX) { + return fail(QStringLiteral("shared analog meter lower mask is invalid")); + } + previousX = point.x(); + } + for (int index = 0; index < normalizedMaskBoundary.size() / 2; ++index) { + const QPointF left = normalizedMaskBoundary[index]; + const QPointF right = normalizedMaskBoundary[normalizedMaskBoundary.size() - 1 - index]; + if (std::abs((left.x() + right.x()) - 1.0) > 1e-6 + || std::abs(left.y() - right.y()) > 1e-6) { + return fail(QStringLiteral("shared analog meter lower mask is not symmetric")); + } + } + const QPointF center = normalizedMaskBoundary.at(normalizedMaskBoundary.size() / 2); + if (std::abs(center.x() - 0.5) > 1e-6) { + return fail(QStringLiteral("shared analog meter lower mask is not symmetric")); + } + if (!validStop(classicGradient.middleStop) + || !validStop(classicGradient.vignetteClearStop) + || !(classicGradient.glowRadius > 0.0) + || !(classicGradient.vignetteRadius > 0.0) + || classicGradient.paperGrainOpacity < 0.0 + || classicGradient.paperGrainOpacity > 0.30) { + return fail(QStringLiteral("classic analog meter material is invalid")); + } + if (!validStop(uplightGradient.middleStop) + || !validStop(uplightGradient.haloMiddleStop) + || !validStop(uplightGradient.haloShoulderStop) + || !(uplightGradient.haloMiddleStop < uplightGradient.haloShoulderStop) + || !validStop(uplightGradient.hotspotMiddleStop) + || !validStop(uplightGradient.bloomMiddleStop) + || !validStop(uplightGradient.vignetteClearStop) + || !(uplightGradient.haloRadius > 0.0) + || !(uplightGradient.hotspotRadius > 0.0) + || !(uplightGradient.bloomRadius > 0.0) + || !(uplightGradient.vignetteRadius > 0.0) + || uplightGradient.paperGrainOpacity < 0.0 + || uplightGradient.paperGrainOpacity > 0.30) { + return fail(QStringLiteral("uplight analog meter material is invalid")); + } + if (!validStop(darkGradient.middleStop) + || !validStop(darkGradient.glowMiddleStop) + || !validStop(darkGradient.vignetteClearStop) + || !(darkGradient.ambientRadius > 0.0) + || !(darkGradient.glowRadius > 0.0) + || !(darkGradient.vignetteRadius > 0.0) + || darkGradient.paperGrainOpacity < 0.0 + || darkGradient.paperGrainOpacity > 0.30) { + return fail(QStringLiteral("graphite analog meter material is invalid")); + } + return true; +} + +const AnalogMeterFaceThemeCatalog::Palette& AnalogMeterFaceThemeCatalog::palette( + AnalogMeterFaceTheme theme) const +{ + switch (theme) { + case AnalogMeterFaceTheme::DarkRoomUplight: + return uplightPalette; + case AnalogMeterFaceTheme::GraphiteDark: + return darkPalette; + case AnalogMeterFaceTheme::AetherDefault: + case AnalogMeterFaceTheme::ClassicWarm: + return classicPalette; + } + return classicPalette; +} + +void AnalogMeterFaceThemeCatalog::drawBackground( + QPainter& painter, const QRectF& face, AnalogMeterFaceTheme theme) const +{ + if (theme == AnalogMeterFaceTheme::AetherDefault) { + return; + } + + painter.save(); + painter.setClipRect(face, Qt::IntersectClip); + painter.translate(face.left(), face.top()); + painter.scale(face.width() / referenceFace.width(), + face.height() / referenceFace.height()); + painter.translate(-referenceFace.left(), -referenceFace.top()); + const QRectF materialFace = referenceFace; + + if (theme == AnalogMeterFaceTheme::GraphiteDark) { + const DarkGradient& dark = darkGradient; + QLinearGradient card(materialFace.topLeft(), materialFace.bottomLeft()); + card.setColorAt(0.0, dark.top); + card.setColorAt(dark.middleStop, dark.middle); + card.setColorAt(1.0, dark.bottom); + painter.fillRect(materialFace, card); + + QRadialGradient ambient(dark.ambientCenter, dark.ambientRadius); + ambient.setColorAt(0.0, dark.ambientInner); + ambient.setColorAt(1.0, dark.ambientOuter); + painter.fillRect(materialFace, ambient); + + painter.save(); + painter.setCompositionMode(QPainter::CompositionMode_Screen); + QRadialGradient glow(dark.glowCenter, dark.glowRadius); + glow.setColorAt(0.0, dark.glowInner); + glow.setColorAt(dark.glowMiddleStop, dark.glowMiddle); + glow.setColorAt(1.0, dark.glowOuter); + painter.fillRect(materialFace, glow); + painter.restore(); + + QColor clearEdge = dark.vignetteEdge; + clearEdge.setAlpha(0); + QRadialGradient vignette(dark.vignetteCenter, dark.vignetteRadius); + vignette.setColorAt(0.0, clearEdge); + vignette.setColorAt(dark.vignetteClearStop, clearEdge); + vignette.setColorAt(1.0, dark.vignetteEdge); + painter.fillRect(materialFace, vignette); + drawPaperGrain(painter, materialFace, dark.paperGrainOpacity); + painter.restore(); + return; + } + + if (theme == AnalogMeterFaceTheme::DarkRoomUplight) { + const UplightGradient& light = uplightGradient; + QLinearGradient ambient(materialFace.topLeft(), materialFace.bottomLeft()); + ambient.setColorAt(0.0, light.top); + ambient.setColorAt(light.middleStop, light.middle); + ambient.setColorAt(1.0, light.bottom); + painter.fillRect(materialFace, ambient); + + QRadialGradient halo(light.haloCenter, light.haloRadius); + halo.setColorAt(0.0, light.haloInner); + halo.setColorAt(light.haloMiddleStop, light.haloMiddle); + halo.setColorAt(light.haloShoulderStop, light.haloShoulder); + halo.setColorAt(1.0, light.haloOuter); + painter.fillRect(materialFace, halo); + + painter.save(); + painter.setCompositionMode(QPainter::CompositionMode_Screen); + QRadialGradient hotspot(light.hotspotCenter, light.hotspotRadius); + hotspot.setColorAt(0.0, light.hotspotInner); + hotspot.setColorAt(light.hotspotMiddleStop, light.hotspotMiddle); + hotspot.setColorAt(1.0, light.hotspotOuter); + painter.fillRect(materialFace, hotspot); + QRadialGradient bloom(light.bloomCenter, light.bloomRadius); + bloom.setColorAt(0.0, light.bloomInner); + bloom.setColorAt(light.bloomMiddleStop, light.bloomMiddle); + bloom.setColorAt(1.0, light.bloomOuter); + painter.fillRect(materialFace, bloom); + painter.restore(); + + QColor clearEdge = light.vignetteEdge; + clearEdge.setAlpha(0); + QRadialGradient vignette(light.vignetteCenter, light.vignetteRadius); + vignette.setColorAt(0.0, clearEdge); + vignette.setColorAt(light.vignetteClearStop, clearEdge); + vignette.setColorAt(1.0, light.vignetteEdge); + painter.fillRect(materialFace, vignette); + drawPaperGrain(painter, materialFace, light.paperGrainOpacity); + painter.restore(); + return; + } + + const FaceGradient& material = classicGradient; + QLinearGradient base(materialFace.topLeft(), materialFace.bottomLeft()); + base.setColorAt(0.0, material.top); + base.setColorAt(material.middleStop, material.middle); + base.setColorAt(1.0, material.bottom); + painter.fillRect(materialFace, base); + QRadialGradient glow(material.glowCenter, material.glowRadius); + glow.setColorAt(0.0, material.glowInner); + glow.setColorAt(1.0, material.glowOuter); + painter.fillRect(materialFace, glow); + QColor clearEdge = material.vignetteEdge; + clearEdge.setAlpha(0); + QRadialGradient vignette(material.vignetteCenter, material.vignetteRadius); + vignette.setColorAt(0.0, clearEdge); + vignette.setColorAt(material.vignetteClearStop, clearEdge); + vignette.setColorAt(1.0, material.vignetteEdge); + painter.fillRect(materialFace, vignette); + drawPaperGrain(painter, materialFace, material.paperGrainOpacity); + painter.restore(); +} + +QVector AnalogMeterFaceThemeCatalog::lowerMaskBoundary( + const QRectF& face) const +{ + QVector boundary; + boundary.reserve(normalizedMaskBoundary.size()); + for (const QPointF& normalized : normalizedMaskBoundary) { + boundary.push_back(QPointF(face.left() + normalized.x() * face.width(), + face.top() + normalized.y() * face.height())); + } + return boundary; +} + +QPainterPath AnalogMeterFaceThemeCatalog::lowerMaskPath(const QRectF& face) const +{ + const QVector boundary = lowerMaskBoundary(face); + QPainterPath path; + if (boundary.isEmpty()) { + return path; + } + const double bottom = face.top() + normalizedMaskBottom * face.height(); + path.moveTo(boundary.first().x(), bottom); + for (const QPointF& point : boundary) { + path.lineTo(point); + } + path.lineTo(boundary.last().x(), bottom); + path.closeSubpath(); + return path; +} + +} // namespace AetherSDR diff --git a/src/gui/AnalogMeterFaceTheme.h b/src/gui/AnalogMeterFaceTheme.h new file mode 100644 index 000000000..67f65df0f --- /dev/null +++ b/src/gui/AnalogMeterFaceTheme.h @@ -0,0 +1,159 @@ +#pragma once + +#include +#include +#include +#include +#include + +class QIODevice; +class QPainter; +class QPainterPath; + +namespace AetherSDR { + +// Shared physical face materials used by both analog meter widgets. The +// standard S-meter adds AetherDefault; the independent PWR meter uses the +// three physical treatments only. +enum class AnalogMeterFaceTheme { + AetherDefault, + ClassicWarm, + DarkRoomUplight, + GraphiteDark, +}; + +QString analogMeterFaceThemeId(AnalogMeterFaceTheme theme); +AnalogMeterFaceTheme analogMeterFaceThemeFromId( + const QString& id, AnalogMeterFaceTheme fallback); + +class AnalogMeterFaceThemeCatalog { +public: + struct FaceGradient { + QColor top; + QColor middle; + QColor bottom; + double middleStop{0.5}; + QPointF glowCenter; + double glowRadius{1.0}; + QColor glowInner; + QColor glowOuter; + QPointF vignetteCenter; + double vignetteRadius{1.0}; + double vignetteClearStop{0.0}; + QColor vignetteEdge; + double paperGrainOpacity{0.0}; + + bool operator==(const FaceGradient&) const = default; + }; + + struct UplightGradient { + QColor top; + QColor middle; + QColor bottom; + double middleStop{0.5}; + QPointF haloCenter; + double haloRadius{1.0}; + QColor haloInner; + QColor haloMiddle; + double haloMiddleStop{0.0}; + QColor haloShoulder; + double haloShoulderStop{0.0}; + QColor haloOuter; + QPointF hotspotCenter; + double hotspotRadius{1.0}; + QColor hotspotInner; + QColor hotspotMiddle; + double hotspotMiddleStop{0.0}; + QColor hotspotOuter; + QPointF bloomCenter; + double bloomRadius{1.0}; + QColor bloomInner; + QColor bloomMiddle; + double bloomMiddleStop{0.0}; + QColor bloomOuter; + QPointF vignetteCenter; + double vignetteRadius{1.0}; + double vignetteClearStop{0.0}; + QColor vignetteEdge; + double paperGrainOpacity{0.0}; + + bool operator==(const UplightGradient&) const = default; + }; + + struct DarkGradient { + QColor top; + QColor middle; + QColor bottom; + double middleStop{0.5}; + QPointF ambientCenter; + double ambientRadius{1.0}; + QColor ambientInner; + QColor ambientOuter; + QPointF glowCenter; + double glowRadius{1.0}; + QColor glowInner; + QColor glowMiddle; + double glowMiddleStop{0.0}; + QColor glowOuter; + QPointF vignetteCenter; + double vignetteRadius{1.0}; + double vignetteClearStop{0.0}; + QColor vignetteEdge; + double paperGrainOpacity{0.0}; + + bool operator==(const DarkGradient&) const = default; + }; + + struct Palette { + QColor ribbon; + QColor scaleOuter; + QColor scaleSeparator; + QColor scaleCalibration; + QColor scaleInner; + QColor majorTick; + QColor minorTick; + QColor text; + QColor secondaryText; + QColor swrGuide; + QColor swrLabel; + QColor needle; + QColor needleEdge; + QColor needleHighlight; + QColor needleShadow; + QColor needleSoftShadow; + QColor maskFill; + QColor maskEdge; + QColor maskText; + + bool operator==(const Palette&) const = default; + }; + + static AnalogMeterFaceThemeCatalog loadResource(QString* error = nullptr); + static AnalogMeterFaceThemeCatalog load(QIODevice& device, QString* error = nullptr); + static AnalogMeterFaceThemeCatalog fallback(); + + bool isValid(QString* error = nullptr) const; + const Palette& palette(AnalogMeterFaceTheme theme) const; + void drawBackground(QPainter& painter, const QRectF& face, + AnalogMeterFaceTheme theme) const; + QPainterPath lowerMaskPath(const QRectF& face) const; + QVector lowerMaskBoundary(const QRectF& face) const; + + // Keep the resource and compiled fallback mechanically comparable. A + // defaulted C++20 equality operator automatically includes every field + // added to the catalog or one of its nested material records. + bool operator==(const AnalogMeterFaceThemeCatalog&) const = default; + + int formatVersion{0}; + QRectF referenceFace; + QVector normalizedMaskBoundary; + double normalizedMaskBottom{1.0}; + FaceGradient classicGradient; + UplightGradient uplightGradient; + DarkGradient darkGradient; + Palette classicPalette; + Palette uplightPalette; + Palette darkPalette; +}; + +} // namespace AetherSDR diff --git a/src/gui/AppletPanel.cpp b/src/gui/AppletPanel.cpp index 8e6eab070..b79cb2947 100644 --- a/src/gui/AppletPanel.cpp +++ b/src/gui/AppletPanel.cpp @@ -95,16 +95,19 @@ MeterSettings::Snapshot loadVuMeterSettings() MeterSettings::LegacyCrossNeedle legacyCrossNeedle; const MeterSettings::Snapshot settings = MeterSettings::decode(raw, &error, &legacyCrossNeedle); + const int storedVersion = QJsonDocument::fromJson(raw).object() + .value(QStringLiteral("version")).toInt(); if (!error.isEmpty()) { qCWarning(lcGui).noquote() << "AppletPanel: ignoring invalid VuMeter settings:" << error; - } else if (legacyCrossNeedle.present) { - // Version 1 temporarily combined both meters. Move its theme into - // the independent PWR feature object and rewrite VuMeter as the - // standard-only version 2 object. Queue every value before one - // save so the AppSettings document changes atomically. - if (!appSettings.contains(PowerMeterSettings::kSettingsKey)) { + } else if (storedVersion < MeterSettings::kVersion) { + // Version 1 temporarily combined both meters. Move that PWR theme + // first, then rewrite either legacy version as the version-3 + // standard-only object with the established Aether face. Queue + // every value before one save so AppSettings changes atomically. + if (legacyCrossNeedle.present + && !appSettings.contains(PowerMeterSettings::kSettingsKey)) { const PowerMeterSettings::Snapshot powerSettings = PowerMeterSettings::migrateLegacyTheme( legacyCrossNeedle.faceTheme); @@ -363,7 +366,10 @@ AppletPanel::AppletPanel(QWidget* parent) : QWidget(parent) m_vuRxSelect = meterSettings.rxSelect; m_vuPeakHoldEnabled = meterSettings.peakHoldEnabled; m_vuPeakDecayRate = meterSettings.peakDecayRate; + m_vuFaceTheme = MeterSettings::normalizeFaceTheme(meterSettings.faceTheme); + m_sMeter->setFaceTheme(analogMeterFaceThemeFromId( + m_vuFaceTheme, AnalogMeterFaceTheme::AetherDefault)); m_sMeter->setTxMode(MeterSettings::txMeterItems()[m_vuTxSelect]); m_sMeter->setRxMode(MeterSettings::rxMeterItems()[m_vuRxSelect]); m_sMeter->setPeakHoldEnabled(m_vuPeakHoldEnabled); @@ -398,6 +404,10 @@ AppletPanel::AppletPanel(QWidget* parent) : QWidget(parent) // tray button toggles its visibility. m_sMeterContainer = m_containerMgr->createContainer("VU", "S-Meter"); m_sMeterContainer->setContent(sMeterContent); + connect(m_sMeterContainer, &ContainerWidget::dockModeChanged, + m_sMeter, [this](ContainerWidget::DockMode mode) { + m_sMeter->setFloating(mode == ContainerWidget::DockMode::Floating); + }); const bool sMeterOn = AppSettings::instance() .value("Applet_VU", "True").toString() == "True"; m_sMeterContainer->setContainerVisible(sMeterOn); @@ -985,6 +995,12 @@ void AppletPanel::setStandardMeterTxValues(float forwardWatts, float swr) m_sMeter->setTxMeters(forwardWatts, swr); } +void AppletPanel::setStandardRadioMeterTxValues( + float forwardWatts, float forwardWattsInstant, float swr) +{ + m_sMeter->setRadioTxMeters(forwardWatts, forwardWattsInstant, swr); +} + void AppletPanel::setCrossNeedleDirectionalValues( float forwardWatts, float reflectedWatts, float swr, bool reflectedPowerMeasured) @@ -1015,6 +1031,7 @@ void AppletPanel::persistVuMeterSettings() const settings.rxSelect = m_vuRxSelect; settings.peakHoldEnabled = m_vuPeakHoldEnabled; settings.peakDecayRate = m_vuPeakDecayRate; + settings.faceTheme = m_vuFaceTheme; AppSettings& appSettings = AppSettings::instance(); appSettings.setValue(MeterSettings::kSettingsKey, @@ -1043,6 +1060,34 @@ void AppletPanel::showStandardMeterContextMenu(QWidget* source, menu.addAction(action); }; + addHeader(QStringLiteral("Face theme")); + QActionGroup* faceThemeGroup = new QActionGroup(&menu); + struct FaceThemeAction { + const char* label; + const char* id; + const char* objectName; + }; + static constexpr FaceThemeAction faceThemes[] = { + {"Aether default", "aether-default", "standardSMeterAetherFaceThemeAction"}, + {"Classic warm", "classic-warm", "standardSMeterClassicFaceThemeAction"}, + {"Dark-room uplight", "dark-room-uplight", "standardSMeterUplightFaceThemeAction"}, + {"Graphite dark", "graphite-dark", "standardSMeterDarkFaceThemeAction"}, + }; + for (const FaceThemeAction& faceTheme : faceThemes) { + const QString id = QString::fromLatin1(faceTheme.id); + QAction* action = menu.addAction(QString::fromLatin1(faceTheme.label)); + action->setObjectName(QString::fromLatin1(faceTheme.objectName)); + action->setCheckable(true); + action->setChecked(m_vuFaceTheme == id); + faceThemeGroup->addAction(action); + connect(action, &QAction::triggered, this, [this, id]() { + m_vuFaceTheme = MeterSettings::normalizeFaceTheme(id); + m_sMeter->setFaceTheme(analogMeterFaceThemeFromId( + m_vuFaceTheme, AnalogMeterFaceTheme::AetherDefault)); + persistVuMeterSettings(); + }); + } + addHeader(QStringLiteral("TX Select")); QActionGroup* txGroup = new QActionGroup(&menu); for (int i = 0; i < MeterSettings::txMeterItems().size(); ++i) { diff --git a/src/gui/AppletPanel.h b/src/gui/AppletPanel.h index 8941cd17e..65bf2720b 100644 --- a/src/gui/AppletPanel.h +++ b/src/gui/AppletPanel.h @@ -82,6 +82,9 @@ class AppletPanel : public QWidget { CrossNeedleMeterWidget* crossNeedleMeterWidget() const; void setMeterTxValues(float forwardWatts, float swr); void setStandardMeterTxValues(float forwardWatts, float swr); + void setStandardRadioMeterTxValues(float forwardWatts, + float forwardWattsInstant, + float swr); void setCrossNeedleDirectionalValues(float forwardWatts, float reflectedWatts, float swr, @@ -273,6 +276,7 @@ class AppletPanel : public QWidget { int m_vuRxSelect{0}; bool m_vuPeakHoldEnabled{false}; QString m_vuPeakDecayRate{QStringLiteral("Medium")}; + QString m_vuFaceTheme{QStringLiteral("aether-default")}; RxApplet* m_rxApplet{nullptr}; TunerApplet* m_tunerApplet{nullptr}; AmpApplet* m_ampApplet{nullptr}; diff --git a/src/gui/CrossNeedleMeterApplet.cpp b/src/gui/CrossNeedleMeterApplet.cpp index 0c85ed7f0..247da6dc2 100644 --- a/src/gui/CrossNeedleMeterApplet.cpp +++ b/src/gui/CrossNeedleMeterApplet.cpp @@ -8,6 +8,7 @@ #include #include +#include #include #include #include @@ -108,21 +109,32 @@ void CrossNeedleMeterApplet::loadSettings() .value(MeterSettings::kSettingsKey, QString()) .toString(); MeterSettings::Snapshot settings; + bool rewriteSettings = false; if (!raw.isEmpty()) { QString error; settings = MeterSettings::decode(raw.toUtf8(), &error); if (!error.isEmpty()) { qCWarning(lcGui).noquote() << "CrossNeedleMeterApplet: ignoring invalid settings:" << error; + } else { + const int storedVersion = + QJsonDocument::fromJson(raw.toUtf8()).object() + .value(QStringLiteral("version")).toInt(); + rewriteSettings = storedVersion < MeterSettings::kVersion; } } setFaceTheme(settings.faceTheme, false); + setRangeLegendVisible(settings.showRange, false); + if (rewriteSettings) { + persistSettings(); + } } void CrossNeedleMeterApplet::persistSettings() const { MeterSettings::Snapshot settings; settings.faceTheme = m_faceTheme; + settings.showRange = m_rangeLegendVisible; AppSettings& appSettings = AppSettings::instance(); appSettings.setValue(MeterSettings::kSettingsKey, MeterSettings::encode(settings)); @@ -146,6 +158,16 @@ void CrossNeedleMeterApplet::setFaceTheme(const QString& theme, bool persist) } } +void CrossNeedleMeterApplet::setRangeLegendVisible(bool visible, bool persist) +{ + m_rangeLegendVisible = visible; + m_meter->setRangeLegendVisible(visible); + setProperty("crossNeedleRangeLegendVisible", visible); + if (persist) { + persistSettings(); + } +} + void CrossNeedleMeterApplet::showContextMenu(const QPoint& position) { QMenu menu(m_meter); @@ -193,6 +215,16 @@ void CrossNeedleMeterApplet::showContextMenu(const QPoint& position) setFaceTheme(MeterSettings::kDarkTheme, true); }); + menu.addSeparator(); + addHeader(tr("Display")); + QAction* showRangeAction = menu.addAction(tr("Show Range")); + showRangeAction->setObjectName(QStringLiteral("crossNeedleShowRangeAction")); + showRangeAction->setCheckable(true); + showRangeAction->setChecked(m_rangeLegendVisible); + connect(showRangeAction, &QAction::toggled, this, [this](bool checked) { + setRangeLegendVisible(checked, true); + }); + if (qEnvironmentVariableIsSet("AETHER_AUTOMATION")) { menu.addSeparator(); addHeader(QStringLiteral("Automation proof")); diff --git a/src/gui/CrossNeedleMeterApplet.h b/src/gui/CrossNeedleMeterApplet.h index e57d8188e..1dab2543c 100644 --- a/src/gui/CrossNeedleMeterApplet.h +++ b/src/gui/CrossNeedleMeterApplet.h @@ -30,10 +30,12 @@ class CrossNeedleMeterApplet : public QWidget { void loadSettings(); void persistSettings() const; void setFaceTheme(const QString& theme, bool persist); + void setRangeLegendVisible(bool visible, bool persist); void showContextMenu(const QPoint& position); CrossNeedleMeterWidget* m_meter{nullptr}; QString m_faceTheme; + bool m_rangeLegendVisible{true}; bool m_floating{false}; }; diff --git a/src/gui/CrossNeedleMeterGeometry.cpp b/src/gui/CrossNeedleMeterGeometry.cpp index 8ee1e6004..0aabe1f35 100644 --- a/src/gui/CrossNeedleMeterGeometry.cpp +++ b/src/gui/CrossNeedleMeterGeometry.cpp @@ -1,14 +1,19 @@ #include "CrossNeedleMeterGeometry.h" #include +#include +#include +#include #include #include #include #include #include +#include #include #include +#include namespace AetherSDR { @@ -43,61 +48,6 @@ QVector readDoubles(const QJsonValue &value) { return result; } -QVector monotoneTangents(const QVector &values, - const QVector &outputs) { - const int count = values.size(); - if (count < 2 || outputs.size() != count) { - return {}; - } - - QVector intervals(count - 1); - QVector slopes(count - 1); - for (int i = 0; i + 1 < count; ++i) { - intervals[i] = values[i + 1] - values[i]; - if (!(intervals[i] > 0.0)) { - return {}; - } - slopes[i] = (outputs[i + 1] - outputs[i]) / intervals[i]; - } - - QVector tangents(count); - if (count == 2) { - tangents[0] = slopes[0]; - tangents[1] = slopes[0]; - return tangents; - } - - for (int i = 1; i + 1 < count; ++i) { - if (slopes[i - 1] * slopes[i] <= 0.0) { - tangents[i] = 0.0; - continue; - } - const double firstWeight = 2.0 * intervals[i] + intervals[i - 1]; - const double secondWeight = intervals[i] + 2.0 * intervals[i - 1]; - tangents[i] = (firstWeight + secondWeight) / - (firstWeight / slopes[i - 1] + secondWeight / slopes[i]); - } - - const auto endpointTangent = [](double firstInterval, double secondInterval, - double firstSlope, double secondSlope) { - double tangent = ((2.0 * firstInterval + secondInterval) * firstSlope - - firstInterval * secondSlope) / - (firstInterval + secondInterval); - if (tangent * firstSlope <= 0.0) { - return 0.0; - } - if (firstSlope * secondSlope < 0.0 && - std::abs(tangent) > 3.0 * std::abs(firstSlope)) { - tangent = 3.0 * firstSlope; - } - return tangent; - }; - tangents[0] = endpointTangent(intervals[0], intervals[1], slopes[0], slopes[1]); - tangents[count - 1] = endpointTangent(intervals[count - 2], intervals[count - 3], - slopes[count - 2], slopes[count - 3]); - return tangents; -} - QStringList readStrings(const QJsonValue &value) { QStringList result; const QJsonArray a = value.toArray(); @@ -118,8 +68,19 @@ CrossNeedleMeterGeometry::Scale readScale(const QJsonObject &o) { scale.startRadians = o.value(QStringLiteral("start_radians")).toDouble(); scale.endRadians = o.value(QStringLiteral("end_radians")).toDouble(); scale.values = readDoubles(o.value(QStringLiteral("values"))); - scale.anglesRadians = readDoubles(o.value(QStringLiteral("angles_radians"))); - scale.angleTangents = monotoneTangents(scale.values, scale.anglesRadians); + scale.referenceAnglesRadians = + readDoubles(o.value(QStringLiteral("reference_angles_radians"))); + const QJsonObject response = o.value(QStringLiteral("response")).toObject(); + scale.responseModel = response.value(QStringLiteral("model")).toString(); + scale.responseStartRadians = + response.value(QStringLiteral("start_radians")).toDouble(); + scale.responseEndRadians = + response.value(QStringLiteral("end_radians")).toDouble(); + scale.responseCoefficients = + readDoubles(response.value(QStringLiteral("coefficients"))); + scale.maximumReferenceErrorPixels = + response.value(QStringLiteral("maximum_reference_error_pixels")) + .toDouble(scale.maximumReferenceErrorPixels); scale.labels = readStrings(o.value(QStringLiteral("labels"))); scale.minorSubdivisions = o.value(QStringLiteral("minor_subdivisions")).toInt(1); scale.labelOffset = o.value(QStringLiteral("label_offset")).toDouble(34.0); @@ -423,6 +384,15 @@ CrossNeedleMeterGeometry CrossNeedleMeterGeometry::load(QIODevice &device, QStri geometry.forwardScale = readScale(root.value(QStringLiteral("forward_scale")).toObject()); geometry.reflectedScale = readScale(root.value(QStringLiteral("reflected_scale")).toObject()); + const auto deriveTickAngles = [](Scale &scale) { + scale.anglesRadians.clear(); + scale.anglesRadians.reserve(scale.values.size()); + for (const double value : scale.values) { + scale.anglesRadians.append(CrossNeedleMeterGeometry::angleForValue(scale, value)); + } + }; + deriveTickAngles(geometry.forwardScale); + deriveTickAngles(geometry.reflectedScale); const QJsonObject titles = root.value(QStringLiteral("titles")).toObject(); const QJsonObject forwardTitle = titles.value(QStringLiteral("forward")).toObject(); @@ -463,6 +433,20 @@ CrossNeedleMeterGeometry CrossNeedleMeterGeometry::load(QIODevice &device, QStri swr.value(QStringLiteral("guide_width")).toDouble(geometry.swrStyle.guideWidth); geometry.swrStyle.label = readColor(swr.value(QStringLiteral("label_rgba")), geometry.swrStyle.label); + geometry.swrStyle.graphClearance = + swr.value(QStringLiteral("graph_clearance")) + .toDouble(geometry.swrStyle.graphClearance); + geometry.swrStyle.maskGap = + swr.value(QStringLiteral("mask_gap")).toDouble(geometry.swrStyle.maskGap); + geometry.swrStyle.curveSamples = + swr.value(QStringLiteral("curve_samples")).toInt(geometry.swrStyle.curveSamples); + geometry.swrStyle.labelArcFraction = + swr.value(QStringLiteral("label_arc_fraction")).toDouble(geometry.swrStyle.labelArcFraction); + geometry.swrStyle.labelDeclutterStep = + swr.value(QStringLiteral("label_declutter_step")) + .toDouble(geometry.swrStyle.labelDeclutterStep); + geometry.swrStyle.labelBoxPadding = + swr.value(QStringLiteral("label_box_padding")).toDouble(geometry.swrStyle.labelBoxPadding); const QJsonArray guides = swr.value(QStringLiteral("guides")).toArray(); geometry.swrGuides.reserve(guides.size()); for (const QJsonValue &value : guides) { @@ -475,11 +459,6 @@ CrossNeedleMeterGeometry CrossNeedleMeterGeometry::load(QIODevice &device, QStri swrValue.toString() == QStringLiteral("infinity") ? std::numeric_limits::infinity() : swrValue.toDouble(); - guide.visibleUpper = readPoint(o.value(QStringLiteral("visible_upper"))); - guide.registeredDatum = readPoint(o.value(QStringLiteral("registered_datum"))); - guide.hiddenLower = readPoint(o.value(QStringLiteral("hidden_lower"))); - guide.quadraticA = o.value(QStringLiteral("quadratic_a")).toDouble(); - guide.labelCenter = readPoint(o.value(QStringLiteral("label_center"))); geometry.swrGuides.append(guide); } @@ -555,8 +534,8 @@ CrossNeedleMeterGeometry CrossNeedleMeterGeometry::load(QIODevice &device, QStri CrossNeedleMeterGeometry CrossNeedleMeterGeometry::fallback() { CrossNeedleMeterGeometry geometry; - geometry.formatVersion = 1; - geometry.designVersion = 12; + geometry.formatVersion = 6; + geometry.designVersion = 19; geometry.rangeLabel = QStringLiteral("RANGE 20 W x1 200 W x10 2 kW x100"); geometry.forwardTitle = {QStringLiteral("FORWARD"), QPointF(180.0, 545.0), -58.0}; geometry.reflectedTitle = {QStringLiteral("REFLECTED"), QPointF(1320.0, 545.0), 58.0}; @@ -565,9 +544,18 @@ CrossNeedleMeterGeometry CrossNeedleMeterGeometry::fallback() { geometry.forwardScale.startRadians = -2.922529943067865; geometry.forwardScale.endRadians = -1.6309954106531042; geometry.forwardScale.values = {0.0, 20.0}; - geometry.forwardScale.anglesRadians = {-2.922529943067865, -1.7009954106531042}; - geometry.forwardScale.angleTangents = - monotoneTangents(geometry.forwardScale.values, geometry.forwardScale.anglesRadians); + geometry.forwardScale.referenceAnglesRadians = {-2.922529943067865, + -1.7009954106531042}; + geometry.forwardScale.responseModel = QStringLiteral("concave_bernstein_v1"); + geometry.forwardScale.responseStartRadians = -2.922529943067865; + geometry.forwardScale.responseEndRadians = -1.7009954106531042; + geometry.forwardScale.responseCoefficients = { + 0.0, 0.4833365491938737, 0.6144073900045368, + 0.7454782308151997, 0.8727391154075999, 1.0}; + geometry.forwardScale.maximumReferenceErrorPixels = 1.0; + geometry.forwardScale.anglesRadians = { + angleForValue(geometry.forwardScale, 0.0), + angleForValue(geometry.forwardScale, 20.0)}; geometry.forwardScale.labels = {QStringLiteral("0"), QStringLiteral("20")}; geometry.forwardScale.minorSubdivisions = 5; geometry.reflectedScale.center = QPointF(401.0, 1057.0); @@ -575,16 +563,23 @@ CrossNeedleMeterGeometry CrossNeedleMeterGeometry::fallback() { geometry.reflectedScale.startRadians = -0.21906271052192816; geometry.reflectedScale.endRadians = -1.510597242936689; geometry.reflectedScale.values = {0.0, 4.0}; - geometry.reflectedScale.anglesRadians = {-0.21906271052192816, -1.440597242936689}; - geometry.reflectedScale.angleTangents = - monotoneTangents(geometry.reflectedScale.values, geometry.reflectedScale.anglesRadians); + geometry.reflectedScale.referenceAnglesRadians = {-0.21906271052192816, + -1.440597242936689}; + geometry.reflectedScale.responseModel = QStringLiteral("concave_bernstein_v1"); + geometry.reflectedScale.responseStartRadians = -0.21906271052192816; + geometry.reflectedScale.responseEndRadians = -1.440597242936689; + geometry.reflectedScale.responseCoefficients = { + 0.0, 0.26723624915834737, 0.5344724983166947, + 0.7871286394467959, 0.8935643197233979, 1.0}; + geometry.reflectedScale.maximumReferenceErrorPixels = 1.0; + geometry.reflectedScale.anglesRadians = { + angleForValue(geometry.reflectedScale, 0.0), + angleForValue(geometry.reflectedScale, 4.0)}; geometry.reflectedScale.labels = {QStringLiteral("0"), QStringLiteral("4")}; geometry.reflectedScale.minorSubdivisions = 2; - geometry.swrGuides = { - {QStringLiteral("1.5"), QStringLiteral("1.5"), 1.5, - QPointF(1049.4649081096002, 591.025257178578), QPointF(957.5, 880.0), - QPointF(887.4859925937714, 1100.0), -0.000102863228292, - QPointF(1095.0, 690.0)}}; + geometry.swrStyle.graphClearance = 60.0; + geometry.swrStyle.curveSamples = 128; + geometry.swrGuides = {{QStringLiteral("1.5"), QStringLiteral("1.5"), 1.5}}; geometry.mask.boundary = {{52.0, 904.0}, {250.0, 904.0}, {500.0, 900.0}, {650.0, 880.0}, {750.0, 872.0}, {850.0, 880.0}, {1000.0, 900.0}, {1250.0, 904.0}, {1448.0, 904.0}}; @@ -599,7 +594,7 @@ bool CrossNeedleMeterGeometry::isValid(QString *error) const { return false; }; - if (formatVersion != 1) { + if (formatVersion != 6) { return fail(QStringLiteral("unsupported cross-needle geometry format version")); } if (designVersion <= 0) { @@ -676,20 +671,88 @@ bool CrossNeedleMeterGeometry::isValid(QString *error) const { return fail(QStringLiteral("cross-needle needle material is invalid")); } const auto validScale = [](const Scale &scale) { - return finitePoint(scale.center) && scale.radius > 0.0 && std::isfinite(scale.radius) && - scale.values.size() >= 2 && scale.values.size() == scale.anglesRadians.size() && - scale.values.size() == scale.angleTangents.size() && - scale.values.size() == scale.labels.size() && - std::is_sorted(scale.values.cbegin(), scale.values.cend()) && - std::all_of(scale.anglesRadians.cbegin(), scale.anglesRadians.cend(), - [](double angle) { return std::isfinite(angle); }) && - std::all_of(scale.angleTangents.cbegin(), scale.angleTangents.cend(), - [](double tangent) { return std::isfinite(tangent); }) && - scale.minorSubdivisions >= 1; + if (!finitePoint(scale.center) || !(scale.radius > 0.0) || + !std::isfinite(scale.radius) || scale.values.size() < 2 || + scale.values.size() != scale.anglesRadians.size() || + scale.values.size() != scale.referenceAnglesRadians.size() || + scale.responseModel != QStringLiteral("concave_bernstein_v1") || + scale.responseCoefficients.size() != 6 || + scale.values.size() != scale.labels.size() || scale.minorSubdivisions < 1) { + return false; + } + const bool anglesIncrease = + scale.anglesRadians.last() > scale.anglesRadians.first(); + for (int index = 1; index < scale.values.size(); ++index) { + if (!(scale.values[index] > scale.values[index - 1]) || + !std::isfinite(scale.anglesRadians[index]) || + !std::isfinite(scale.referenceAnglesRadians[index]) || + ((scale.anglesRadians[index] > scale.anglesRadians[index - 1]) != + anglesIncrease) || + ((scale.referenceAnglesRadians[index] > + scale.referenceAnglesRadians[index - 1]) != anglesIncrease)) { + return false; + } + } + if (!std::isfinite(scale.anglesRadians.first()) || + !std::isfinite(scale.referenceAnglesRadians.first()) || + !std::isfinite(scale.responseStartRadians) || + !std::isfinite(scale.responseEndRadians) || + scale.responseEndRadians == scale.responseStartRadians || + (scale.responseEndRadians > scale.responseStartRadians) != anglesIncrease || + !(scale.maximumReferenceErrorPixels > 0.0) || + !std::isfinite(scale.maximumReferenceErrorPixels) || + std::abs(scale.responseCoefficients.first()) > 1e-12 || + std::abs(scale.responseCoefficients.last() - 1.0) > 1e-12) { + return false; + } + for (int index = 0; index < scale.responseCoefficients.size(); ++index) { + const double coefficient = scale.responseCoefficients[index]; + if (!std::isfinite(coefficient) || coefficient < 0.0 || coefficient > 1.0 || + (index > 0 && + coefficient + 1e-12 < scale.responseCoefficients[index - 1])) { + return false; + } + } + // Concave control polygon (non-positive second differences): sensitivity + // decreases with power, so calibration noise cannot knot the contours. + for (int index = 0; index + 2 < scale.responseCoefficients.size(); ++index) { + const double secondDifference = scale.responseCoefficients[index + 2] - + 2.0 * scale.responseCoefficients[index + 1] + + scale.responseCoefficients[index]; + if (secondDifference > 1e-12) { + return false; + } + } + for (int index = 0; index < scale.values.size(); ++index) { + if (std::abs(CrossNeedleMeterGeometry::angleForValue( + scale, scale.values[index]) - + scale.anglesRadians[index]) > 1e-12) { + return false; + } + } + return true; }; if (!validScale(forwardScale) || !validScale(reflectedScale)) { return fail(QStringLiteral("cross-needle power scale arrays are invalid")); } + const auto scaleFitIsValid = [](const Scale &scale) { + // The needle parks on the printed zero (angled rest), so every tick + // including 0 is constrained by the photographed calibration. + for (int index = 0; index < scale.values.size(); ++index) { + const double fittedAngle = + CrossNeedleMeterGeometry::angleForValue(scale, scale.values[index]); + const double errorPixels = + std::abs(fittedAngle - scale.referenceAnglesRadians[index]) * scale.radius; + if (!std::isfinite(errorPixels) || + errorPixels > scale.maximumReferenceErrorPixels) { + return false; + } + } + return true; + }; + if (!scaleFitIsValid(forwardScale) || !scaleFitIsValid(reflectedScale)) { + return fail(QStringLiteral("cross-needle movement fit exceeds tick tolerance")); + } if (!std::isfinite(scaleOverlap.reflectedGapCenterRadians) || !(scaleOverlap.reflectedGapHalfSpanRadians > 0.0 && scaleOverlap.reflectedGapHalfSpanRadians < 0.05) || @@ -697,70 +760,152 @@ bool CrossNeedleMeterGeometry::isValid(QString *error) const { !(scaleOverlap.reflectedGapWidth > 0.0)) { return fail(QStringLiteral("cross-needle scale overlap is invalid")); } - if (swrGuides.isEmpty()) { - return fail(QStringLiteral("cross-needle SWR guide set is empty")); + if (swrGuides.isEmpty() || !(swrStyle.graphClearance > 0.0) || + swrStyle.graphClearance >= std::min(forwardScale.radius, reflectedScale.radius) || + swrStyle.curveSamples < 32 || swrStyle.curveSamples > 1024 || + !(swrStyle.labelArcFraction > 0.0) || !(swrStyle.labelArcFraction <= 1.0) || + !(swrStyle.labelDeclutterStep > 0.0) || !std::isfinite(swrStyle.labelDeclutterStep) || + !(swrStyle.labelBoxPadding >= 0.0) || !std::isfinite(swrStyle.labelBoxPadding) || + !(swrStyle.maskGap >= 0.0) || !std::isfinite(swrStyle.maskGap)) { + return fail(QStringLiteral("cross-needle SWR construction settings are invalid")); + } + bool validMaskBoundary = mask.boundary.size() >= 3; + double previousMaskX = -std::numeric_limits::infinity(); + double maximumMaskY = -std::numeric_limits::infinity(); + for (const QPointF &point : mask.boundary) { + validMaskBoundary = validMaskBoundary && finitePoint(point) && + point.x() >= 0.0 && point.x() <= canvasWidth && + point.y() >= 0.0 && point.y() <= canvasHeight && + point.x() > previousMaskX; + previousMaskX = point.x(); + maximumMaskY = std::max(maximumMaskY, point.y()); + } + for (qsizetype index = 0; index < mask.boundary.size(); ++index) { + const QPointF &left = mask.boundary[index]; + const QPointF &right = mask.boundary[mask.boundary.size() - 1 - index]; + validMaskBoundary = validMaskBoundary && + std::abs(left.x() + right.x() - canvasWidth) <= 0.01 && + std::abs(left.y() - right.y()) <= 0.01; } + if (!validMaskBoundary || !std::isfinite(mask.bottomY) || + mask.bottomY < maximumMaskY || mask.bottomY > canvasHeight || + mask.label.trimmed().isEmpty() || !finitePoint(mask.labelCenter) || + mask.labelCenter.x() < 0.0 || mask.labelCenter.x() > canvasWidth || + mask.labelCenter.y() < 0.0 || mask.labelCenter.y() > canvasHeight) { + return fail(QStringLiteral("cross-needle lower mask is invalid")); + } + swrLabelCenterCache.clear(); + swrLabelCenterCacheFontKey.clear(); + swrLabelPlacementError.clear(); + QFont swrLabelFont; + if (QGuiApplication::instance()) { + swrLabelFont = QGuiApplication::font(); + } + swrLabelFont.setPixelSize(typography.swrNumberPixels); + swrLabelFont.setBold(true); for (const SwrGuide &guide : swrGuides) { const bool validSwr = guide.swr > 1.0; - const bool validConstruction = - finitePoint(guide.visibleUpper) && finitePoint(guide.registeredDatum) && - finitePoint(guide.hiddenLower) && std::isfinite(guide.quadraticA) && - guide.visibleUpper.y() > 0.0 && guide.visibleUpper.y() < canvasHeight && - guide.visibleUpper.y() < guide.registeredDatum.y() && - guide.registeredDatum.y() < guide.hiddenLower.y(); const bool validLabel = - guide.displayLabel.isEmpty() || - (finitePoint(guide.labelCenter) && guide.labelCenter.x() > 0.0 && - guide.labelCenter.x() < canvasWidth && guide.labelCenter.y() > 0.0 && - guide.labelCenter.y() < canvasHeight); - const double visibleYSpan = guide.registeredDatum.y() - guide.visibleUpper.y(); - const double maximumVisibleBulge = - std::abs(guide.quadraticA) * visibleYSpan * visibleYSpan / 4.0; - if (guide.label.isEmpty() || !validSwr || !validConstruction || !validLabel || - maximumVisibleBulge > 6.01) { + guide.displayLabel.isEmpty() || !guide.displayLabel.trimmed().isEmpty(); + if (guide.label.isEmpty() || !validSwr || !validLabel) { return fail(QStringLiteral("cross-needle SWR guide '%1' is invalid").arg(guide.label)); } const QPainterPath path = swrGuidePath(guide); - if (path.elementCount() != 7 || path.elementAt(0).type != QPainterPath::MoveToElement || - path.elementAt(1).type != QPainterPath::CurveToElement || - path.elementAt(4).type != QPainterPath::CurveToElement) { - return fail(QStringLiteral("cross-needle SWR guide '%1' path is invalid") - .arg(guide.label)); + if (path.elementCount() != swrStyle.curveSamples + 1 || + path.elementAt(0).type != QPainterPath::MoveToElement || + !finitePoint(path.currentPosition())) { + return fail(QStringLiteral("cross-needle SWR guide '%1' path is invalid " + "(%2 of %3 samples)") + .arg(guide.label) + .arg(path.elementCount()) + .arg(swrStyle.curveSamples + 1)); + } + if (!guide.displayLabel.isEmpty()) { + const QPointF labelCenter = swrGuideLabelCenter(guide, swrLabelFont); + if (!swrLabelPlacementError.isEmpty()) { + return fail(swrLabelPlacementError); + } + if (!finitePoint(labelCenter) || labelCenter.x() <= 0.0 || + labelCenter.x() >= canvasWidth || labelCenter.y() <= 0.0 || + labelCenter.y() >= canvasHeight) { + return fail(QStringLiteral("cross-needle SWR guide '%1' label is outside the face") + .arg(guide.label)); + } } } - if (mask.boundary.size() < 3) { - return fail(QStringLiteral("cross-needle lower mask is invalid")); + if (!swrLabelPlacementError.isEmpty()) { + return fail(swrLabelPlacementError); } return true; } -double CrossNeedleMeterGeometry::interpolate(const Scale &scale, double value) { +double CrossNeedleMeterGeometry::angleForValue(const Scale &scale, double value) { + if (scale.values.size() < 2 || scale.responseCoefficients.size() != 6) { + return 0.0; + } + const double span = scale.values.last() - scale.values.first(); + const double normalizedPower = + span > 0.0 ? std::clamp((value - scale.values.first()) / span, 0.0, 1.0) : 0.0; + return angleForNormalizedPower(scale, normalizedPower); +} + +double CrossNeedleMeterGeometry::angleForNormalizedPower(const Scale &scale, + double normalizedPower) { + if (scale.responseCoefficients.size() != 6) { + return scale.responseStartRadians; + } + // Degree-5 Bernstein in normalized POWER (concave, square-root-like), + // evaluated by de Casteljau. normalizedPower 0 maps to the angled + // printed-zero rest. Callers pass values in [0, 1] for live/tick use; + // SWR-contour construction passes values > 1 to extrapolate a movement a + // little past full scale so every contour can reach the common termination + // envelope (see swrGuidePath / docs/cross-needle-meter-math.md D1). + std::array work{}; + std::copy(scale.responseCoefficients.cbegin(), scale.responseCoefficients.cend(), + work.begin()); + for (int remaining = static_cast(scale.responseCoefficients.size()) - 1; + remaining > 0; --remaining) { + for (int index = 0; index < remaining; ++index) { + work[index] = std::lerp(work[index], work[index + 1], normalizedPower); + } + } + return std::lerp(scale.responseStartRadians, scale.responseEndRadians, work[0]); +} + +double CrossNeedleMeterGeometry::printedAngleForIndex(const Scale &scale, int index) { + if (index < 0 || index >= scale.anglesRadians.size()) { + return 0.0; + } + // The needle parks on the printed zero, so every tick (including 0) uses + // the calibrated movement angle directly. + return scale.anglesRadians[index]; +} + +double CrossNeedleMeterGeometry::inverseInterpolate(const Scale &scale, + double angleRadians) { if (scale.values.size() < 2 || scale.values.size() != scale.anglesRadians.size() || - scale.values.size() != scale.angleTangents.size()) { + scale.responseCoefficients.size() != 6) { return 0.0; } - const double clamped = std::clamp(value, scale.values.first(), scale.values.last()); - const auto upper = std::lower_bound(scale.values.cbegin(), scale.values.cend(), clamped); - if (upper == scale.values.cbegin()) { - return scale.anglesRadians.first(); - } - if (upper == scale.values.cend()) { - return scale.anglesRadians.last(); - } - const int upperIndex = static_cast(std::distance(scale.values.cbegin(), upper)); - const int lowerIndex = upperIndex - 1; - const double span = scale.values[upperIndex] - scale.values[lowerIndex]; - const double fraction = (clamped - scale.values[lowerIndex]) / span; - const double fractionSquared = fraction * fraction; - const double fractionCubed = fractionSquared * fraction; - const double lowerBasis = 2.0 * fractionCubed - 3.0 * fractionSquared + 1.0; - const double lowerTangentBasis = fractionCubed - 2.0 * fractionSquared + fraction; - const double upperBasis = -2.0 * fractionCubed + 3.0 * fractionSquared; - const double upperTangentBasis = fractionCubed - fractionSquared; - return lowerBasis * scale.anglesRadians[lowerIndex] + - lowerTangentBasis * span * scale.angleTangents[lowerIndex] + - upperBasis * scale.anglesRadians[upperIndex] + - upperTangentBasis * span * scale.angleTangents[upperIndex]; + + const bool increasing = scale.anglesRadians.last() > scale.anglesRadians.first(); + const double minimumAngle = + std::min(scale.anglesRadians.first(), scale.anglesRadians.last()); + const double maximumAngle = + std::max(scale.anglesRadians.first(), scale.anglesRadians.last()); + const double target = std::clamp(angleRadians, minimumAngle, maximumAngle); + double lower = scale.values.first(); + double upper = scale.values.last(); + for (int iteration = 0; iteration < 64; ++iteration) { + const double middle = (lower + upper) * 0.5; + const double angle = angleForValue(scale, middle); + if ((angle < target) == increasing) { + lower = middle; + } else { + upper = middle; + } + } + return (lower + upper) * 0.5; } QPointF CrossNeedleMeterGeometry::pointOnScale(const Scale &scale, double angleRadians) { @@ -769,12 +914,12 @@ QPointF CrossNeedleMeterGeometry::pointOnScale(const Scale &scale, double angleR double CrossNeedleMeterGeometry::forwardAngle(double forwardWatts, double multiplier) const { const double safeMultiplier = multiplier > 0.0 ? multiplier : 1.0; - return interpolate(forwardScale, std::max(0.0, forwardWatts) / safeMultiplier); + return angleForValue(forwardScale, std::max(0.0, forwardWatts) / safeMultiplier); } double CrossNeedleMeterGeometry::reflectedAngle(double reflectedWatts, double multiplier) const { const double safeMultiplier = multiplier > 0.0 ? multiplier : 1.0; - return interpolate(reflectedScale, std::max(0.0, reflectedWatts) / safeMultiplier); + return angleForValue(reflectedScale, std::max(0.0, reflectedWatts) / safeMultiplier); } QPointF CrossNeedleMeterGeometry::forwardTip(double forwardWatts, double multiplier) const { @@ -816,44 +961,342 @@ QPointF CrossNeedleMeterGeometry::needleIntersection(double forwardWatts, double QPainterPath CrossNeedleMeterGeometry::swrGuidePath(const SwrGuide &guide) const { QPainterPath path; - const double visibleYSpan = guide.registeredDatum.y() - guide.visibleUpper.y(); - const double hiddenYSpan = guide.hiddenLower.y() - guide.registeredDatum.y(); - if (!(visibleYSpan > 0.0 && hiddenYSpan > 0.0)) { + if (!(guide.swr > 1.0) || swrStyle.curveSamples < 2) { return path; } - // Port of the approved V12 construction. The visible source-traced - // quadratic is represented exactly as a cubic Hermite segment. Its end - // tangent then feeds the concealed Hermite continuation, so there is no - // splice at the mask boundary and no mechanically invented S-curve. - const double visibleChordSlope = - (guide.registeredDatum.x() - guide.visibleUpper.x()) / visibleYSpan; - const double upperSlope = visibleChordSlope - guide.quadraticA * visibleYSpan; - const double datumSlope = visibleChordSlope + guide.quadraticA * visibleYSpan; - const double lowerSlope = - (guide.hiddenLower.x() - guide.registeredDatum.x()) / hiddenYSpan; - - path.moveTo(guide.visibleUpper); - path.cubicTo( - guide.visibleUpper + QPointF(upperSlope * visibleYSpan / 3.0, - visibleYSpan / 3.0), - guide.registeredDatum - QPointF(datumSlope * visibleYSpan / 3.0, - visibleYSpan / 3.0), - guide.registeredDatum); - path.cubicTo( - guide.registeredDatum + QPointF(datumSlope * hiddenYSpan / 3.0, - hiddenYSpan / 3.0), - guide.hiddenLower - QPointF(lowerSlope * hiddenYSpan / 3.0, - hiddenYSpan / 3.0), - guide.hiddenLower); + // A cross-needle SWR guide is the locus of intersections produced by a + // constant reflected/forward power ratio. Sample in detector-voltage + // space, not power space: the movement input is proportional to sqrt(P), + // and uniform voltage steps avoid over-tessellating one part of a curve + // while starving another. + const double ratio = std::isinf(guide.swr) + ? 1.0 + : std::pow((guide.swr - 1.0) / (guide.swr + 1.0), 2.0); + const double forwardMaximum = forwardScale.values.last(); + const double reflectedMaximum = reflectedScale.values.last(); + + // Every contour terminates on ONE common boundary: a fixed clearance short + // of whichever power arc is nearer. Low-SWR crossings reach that boundary + // only a little past full forward scale, so we extend the movement angles + // slightly past unity (angleForNormalizedPower, unclamped) to get there. + // This makes the whole SWR family a consistent fan parallel to the arcs + // (see docs/cross-needle-meter-math.md D1). Live needles are unaffected. + const auto point = [this, ratio, forwardMaximum, reflectedMaximum](double voltage) { + const double forwardNormalized = voltage * voltage; + const double reflectedNormalized = + forwardNormalized * forwardMaximum * ratio / reflectedMaximum; + return lineIntersection( + forwardScale.center, + pointOnScale(forwardScale, + angleForNormalizedPower(forwardScale, forwardNormalized)), + reflectedScale.center, + pointOnScale(reflectedScale, + angleForNormalizedPower(reflectedScale, reflectedNormalized))); + }; + const auto reachesGraphEnvelope = [this](const QPointF &p) { + const double forwardDistance = + std::hypot(p.x() - forwardScale.center.x(), p.y() - forwardScale.center.y()); + const double reflectedDistance = + std::hypot(p.x() - reflectedScale.center.x(), p.y() - reflectedScale.center.y()); + return forwardDistance >= forwardScale.radius - swrStyle.graphClearance || + reflectedDistance >= reflectedScale.radius - swrStyle.graphClearance; + }; + + // Cap on how far past full scale a movement may be extrapolated to reach + // the boundary (~2.5x full-scale power). Step outward from the hidden + // convergence to the FIRST envelope crossing, then bisect for precision. + constexpr double kMaxVoltage = 1.6; + double endingVoltage = kMaxVoltage; + double previous = 0.0; + constexpr int kProbe = 256; + for (int i = 1; i <= kProbe; ++i) { + const double v = kMaxVoltage * static_cast(i) / kProbe; + if (reachesGraphEnvelope(point(v))) { + double lower = previous; + double upper = v; + for (int iteration = 0; iteration < 48; ++iteration) { + const double middle = (lower + upper) * 0.5; + if (reachesGraphEnvelope(point(middle))) { + upper = middle; + } else { + lower = middle; + } + } + endingVoltage = (lower + upper) * 0.5; + break; + } + previous = v; + } + + // The needles rest on their printed zeros (angled rest), so voltage 0 is a + // well-defined crossing: the single hidden convergence just below the mask + // that every contour fans from. Start the path there and sweep to the + // common boundary. + for (int sample = 0; sample <= swrStyle.curveSamples; ++sample) { + const double fraction = + static_cast(sample) / static_cast(swrStyle.curveSamples); + const QPointF p = point(endingVoltage * fraction); + if (!std::isfinite(p.x()) || !std::isfinite(p.y())) { + return {}; + } + if (sample == 0) { + path.moveTo(p); + } else { + path.lineTo(p); + } + } return path; } -QPointF CrossNeedleMeterGeometry::swrGuideLabelCenter(const SwrGuide &guide) const { +double CrossNeedleMeterGeometry::maskBoundaryY(double x) const { + if (mask.boundary.isEmpty()) { + return mask.bottomY; + } + if (x <= mask.boundary.first().x()) { + return mask.boundary.first().y(); + } + for (int index = 1; index < mask.boundary.size(); ++index) { + const QPointF &first = mask.boundary[index - 1]; + const QPointF &second = mask.boundary[index]; + if (x <= second.x()) { + if (second.x() == first.x()) { + return std::min(first.y(), second.y()); + } + const double fraction = (x - first.x()) / (second.x() - first.x()); + return first.y() + (second.y() - first.y()) * fraction; + } + } + return mask.boundary.last().y(); +} + +QRectF CrossNeedleMeterGeometry::swrLabelBox( + const QPointF ¢er, const SwrGuide &guide, const QFont &labelFont) const +{ + const QString display = guide.displayLabel == QStringLiteral("infinity") + ? QString::fromUtf8("\xe2\x88\x9e") + : guide.displayLabel; + QRectF box; + if (QGuiApplication::instance()) { + // Match the rendered number exactly (same font, size, weight, and the + // small background inset the painter and tests use) so a declutter + // decision made here is the one the device sees. + box = QFontMetricsF(labelFont) + .boundingRect(display) + .adjusted(-3.0, -2.0, 3.0, 2.0); + } else { + // Headless fallback: a proportional estimate a touch larger than the + // glyphs, so decisions stay conservative without a font available. + const double pixels = static_cast(typography.swrNumberPixels); + double width = 0.0; + for (const QChar character : display) { + width += character == QLatin1Char('.') ? 0.36 * pixels + : display == QString::fromUtf8("\xe2\x88\x9e") ? 1.05 * pixels + : 0.62 * pixels; + } + box = QRectF(0.0, 0.0, width + 6.0, 1.30 * pixels); + } + box.moveCenter(center); + return box; +} + +QVector CrossNeedleMeterGeometry::swrLabelCenters(const QFont &labelFont) const { + const QString fontKey = labelFont.key(); + if (swrLabelCenterCache.size() == swrGuides.size() && + swrLabelCenterCacheFontKey == fontKey) { + return swrLabelCenterCache; + } + swrLabelPlacementError.clear(); + // Every number rides its own contour: the anchor is a fraction + // (labelArcFraction) of that contour's VISIBLE arc length, then decluttered. + // No per-guide coordinates. + QVector centers(swrGuides.size(), + QPointF(std::numeric_limits::quiet_NaN(), + std::numeric_limits::quiet_NaN())); + QVector paths(swrGuides.size()); + for (int index = 0; index < swrGuides.size(); ++index) { + paths[index] = swrGuidePath(swrGuides[index]); + } + + const auto pointAtDistance = [](const QPainterPath &path, double target, QPointF *tangent) { + if (path.isEmpty()) { + return QPointF(std::numeric_limits::quiet_NaN(), + std::numeric_limits::quiet_NaN()); + } + double traversed = 0.0; + QPointF first = path.elementAt(0); + for (int element = 1; element < path.elementCount(); ++element) { + const QPointF second = path.elementAt(element); + const double segment = std::hypot(second.x() - first.x(), second.y() - first.y()); + if (traversed + segment >= target && segment > 1e-12) { + if (tangent) { + *tangent = (second - first) / segment; + } + return first + (second - first) * ((target - traversed) / segment); + } + traversed += segment; + first = second; + } + QPointF finalTangent(0.0, -1.0); + if (path.elementCount() >= 2) { + const QPointF before = path.elementAt(path.elementCount() - 2); + const QPointF end = path.currentPosition(); + const double segment = std::hypot(end.x() - before.x(), end.y() - before.y()); + finalTangent = segment > 1e-12 ? (end - before) / segment : QPointF(0.0, -1.0); + } + if (tangent) { + *tangent = finalTangent; + } + return path.currentPosition() + finalTangent * std::max(0.0, target - traversed); + }; + + QVector powerNumberBoxes; + if (QGuiApplication::instance()) { + QFont scaleFont = labelFont; + scaleFont.setPixelSize(typography.scaleNumberPixels); + scaleFont.setWeight(QFont::Medium); + const QFontMetricsF metrics(scaleFont); + const auto appendScaleBoxes = [&](const Scale &scale) { + for (int index = 0; index < scale.labels.size(); ++index) { + if (scale.labels[index].isEmpty()) { + continue; + } + const double angle = printedAngleForIndex(scale, index); + const QPointF radial(std::cos(angle), std::sin(angle)); + const QPointF center = pointOnScale(scale, angle) + radial * scale.labelOffset; + QRectF box = metrics.boundingRect(scale.labels[index]); + box.moveCenter(center); + powerNumberBoxes.append(box); + } + }; + appendScaleBoxes(forwardScale); + appendScaleBoxes(reflectedScale); + } + + const double gap = swrStyle.labelBoxPadding; + QVector placed; + placed.reserve(swrGuides.size()); + for (int index = 0; index < swrGuides.size(); ++index) { + const SwrGuide &guide = swrGuides[index]; + const auto boxClears = [&](const QPointF &candidate) { + const QRectF box = swrLabelBox(candidate, guide, labelFont); + if (!(box.bottom() + 3.0 < maskBoundaryY(box.left()) && + box.bottom() + 3.0 < maskBoundaryY(box.center().x()) && + box.bottom() + 3.0 < maskBoundaryY(box.right()) && + box.left() > frame.faceInset && box.right() < canvasWidth - frame.faceInset && + box.top() > frame.faceInset && box.bottom() < mask.bottomY)) { + return false; + } + const QRectF spaced = box.adjusted(-gap, -gap, gap, gap); + for (const QRectF &other : placed) { + if (spaced.intersects(other)) { + return false; + } + } + for (const QRectF &powerBox : powerNumberBoxes) { + if (spaced.intersects(powerBox)) { + return false; + } + } + for (int other = 0; other < paths.size(); ++other) { + if (other != index && paths[other].intersects(spaced)) { + return false; + } + } + return true; + }; + // Anchor within the VISIBLE portion of the contour (above the mask): + // walk from the mask crossing a fraction of the visible arc length, so + // short low-SWR contours and long high-SWR contours label in the same + // relative near-the-outer-end region rather than in the hidden crowd. + double totalLength = 0.0; + double visibleStart = -1.0; + for (int element = 1; element < paths[index].elementCount(); ++element) { + const QPointF a = paths[index].elementAt(element - 1); + const QPointF b = paths[index].elementAt(element); + if (visibleStart < 0.0 && b.y() < maskBoundaryY(b.x())) { + visibleStart = totalLength; + } + totalLength += std::hypot(b.x() - a.x(), b.y() - a.y()); + } + if (visibleStart < 0.0) { + visibleStart = 0.0; + } + const double baseDistance = + visibleStart + swrStyle.labelArcFraction * (totalLength - visibleStart); + bool found = false; + for (int attempt = 0; attempt <= 256; ++attempt) { + const int magnitude = (attempt + 1) / 2; + const int direction = attempt == 0 ? 0 : (attempt % 2 == 1 ? 1 : -1); + // No upper clamp: distances beyond the contour length extrapolate + // along the upper tangent, letting a crowded low-SWR label (e.g. + // 1.1, whose visible stub is too short to host a box) ride up its + // own continuation into open space while staying nearest itself. + const double distance = std::max( + baseDistance + direction * magnitude * swrStyle.labelDeclutterStep, 0.0); + QPointF tangent; + const QPointF anchor = pointAtDistance(paths[index], distance, &tangent); + const QPointF normal(-tangent.y(), tangent.x()); + for (int normalAttempt = 0; normalAttempt <= 128; ++normalAttempt) { + const int normalMagnitude = (normalAttempt + 1) / 2; + const int normalDirection = + normalAttempt == 0 ? 0 : (normalAttempt % 2 == 1 ? 1 : -1); + const QPointF candidate = + anchor + normal * (normalDirection * normalMagnitude * + swrStyle.labelDeclutterStep); + if (boxClears(candidate)) { + centers[index] = candidate; + placed.append(swrLabelBox(candidate, guide, labelFont)); + found = true; + break; + } + } + if (found) { + break; + } + } + if (!found) { + swrLabelPlacementError = + QStringLiteral("cross-needle SWR guide '%1' has no collision-free label position") + .arg(guide.label); + break; + } + } + swrLabelCenterCache = centers; + swrLabelCenterCacheFontKey = fontKey; + return centers; +} + +QPointF CrossNeedleMeterGeometry::swrGuideLabelCenter( + const SwrGuide &guide, const QFont &labelFont) const +{ if (guide.displayLabel.isEmpty()) { return {}; } - return guide.labelCenter; + const QVector centers = swrLabelCenters(labelFont); + for (int index = 0; index < swrGuides.size(); ++index) { + if (swrGuides[index].label == guide.label) { + return centers[index]; + } + } + return {}; +} + +QPointF CrossNeedleMeterGeometry::swrGuideUpperEndpoint(const SwrGuide &guide) const { + return swrGuidePath(guide).currentPosition(); +} + +QPointF CrossNeedleMeterGeometry::powerReadingsAtIntersection( + const QPointF &point, double rangeMultiplier) const { + const double safeMultiplier = rangeMultiplier > 0.0 ? rangeMultiplier : 1.0; + const double forwardAngleRadians = + std::atan2(point.y() - forwardScale.center.y(), + point.x() - forwardScale.center.x()); + const double reflectedAngleRadians = + std::atan2(point.y() - reflectedScale.center.y(), + point.x() - reflectedScale.center.x()); + return QPointF(inverseInterpolate(forwardScale, forwardAngleRadians) * safeMultiplier, + inverseInterpolate(reflectedScale, reflectedAngleRadians) * safeMultiplier); } double CrossNeedleMeterGeometry::distanceToGuide(const QPointF &point, diff --git a/src/gui/CrossNeedleMeterGeometry.h b/src/gui/CrossNeedleMeterGeometry.h index 3abe88511..8f361575f 100644 --- a/src/gui/CrossNeedleMeterGeometry.h +++ b/src/gui/CrossNeedleMeterGeometry.h @@ -8,6 +8,7 @@ #include class QIODevice; +class QFont; namespace AetherSDR { @@ -17,6 +18,11 @@ namespace AetherSDR { // This class validates that resource and owns the mechanical mappings used by // both the painter and tests: non-linear scale interpolation, concealed-pivot // needle rays, constant-SWR curves, and their intersection. +// +// BEFORE changing the response model, contour construction, or label placement, +// read docs/cross-needle-meter-math.md. It is the authoritative model + decision +// record: which geometry choices are settled and why. Reshaping the physics +// without updating that doc (and the tests) is how this component churns. class CrossNeedleMeterGeometry { public: struct Frame { @@ -170,10 +176,29 @@ class CrossNeedleMeterGeometry { double startRadians{0.0}; double endRadians{0.0}; QVector values; + // Runtime calibration angles are derived from the response below. + // The photographed reference angles remain separate evidence: they + // guide the fit but never compete with the response used by ticks, + // needles, and SWR construction. QVector anglesRadians; - // Derived monotone-cubic tangents. They preserve every calibrated - // tick while avoiding visible elbows between adjacent intervals. - QVector angleTangents; + QVector referenceAnglesRadians; + // A normalized degree-5 Bernstein response maps normalized power + // [0, 1] to needle deflection [0, 1]. Its non-decreasing, concave + // control polygon gives natural square-root-like compression (a real + // D'Arsonval movement + sqrt watt scale) while forbidding repeated + // acceleration, so calibration noise cannot knot the SWR contours. + // The response starts at the ANGLED printed-zero rest position + // (responseStartRadians == the "0" tick angle == startRadians): at + // zero power each needle parks on its printed 0 mark, pointing up into + // the dial exactly like a real cross-needle meter, so the low-SWR + // contours stay visible and rise at a shallow angle. One response + // drives printed ticks, live needles, inverse readings, and every SWR + // contour. See docs/cross-needle-meter-math.md, Decision D1. + QString responseModel{QStringLiteral("concave_bernstein_v1")}; + double responseStartRadians{0.0}; + double responseEndRadians{0.0}; + QVector responseCoefficients; + double maximumReferenceErrorPixels{30.0}; QStringList labels; int minorSubdivisions{1}; double labelOffset{34.0}; @@ -182,7 +207,7 @@ class CrossNeedleMeterGeometry { struct Title { QString text; QPointF center; - // Authored with the same sign convention as the V12 proof generator. + // Authored with the same image-space sign convention as the proof renderer. double rotationDegrees{0.0}; }; @@ -199,23 +224,39 @@ class CrossNeedleMeterGeometry { struct SwrGuide { QString label; QString displayLabel; - // V12 source-registered construction: a restrained quadratic from - // the traced upper endpoint to the y=880 datum, then a C1 Hermite - // continuation behind the lower mask. These fields intentionally - // preserve the physical face instead of recomputing a different fan - // from the movement calibration. + // Semantic constant-SWR construction. The path is generated from the + // two calibrated movement maps; the number's position is then derived + // (see swrLabelCenters) rather than authored per guide. double swr{1.0}; - QPointF visibleUpper; - QPointF registeredDatum; - QPointF hiddenLower; - double quadraticA{0.0}; - QPointF labelCenter; }; struct SwrStyle { QColor guide{161, 74, 58, 230}; double guideWidth{3.2}; QColor label{45, 45, 42}; + // Common termination envelope: EVERY contour ends this far short of + // whichever power arc is nearer, so the SWR family is a consistent fan + // (design 19). Low-SWR contours extend a little past full scale to + // reach it; see swrGuidePath and docs/cross-needle-meter-math.md D1. + double graphClearance{60.0}; + // Trim each contour's lower (leading) end this many design pixels above + // the mask boundary, leaving a visible gap between the line and the + // mask like the real meter face. Purely a render trim; the geometry + // path is unchanged. + double maskGap{14.0}; + int curveSamples{128}; + // Derived SWR-number placement. Every label rides its own contour, + // anchored at labelArcFraction of that contour's visible arc length. + // Crowded labels move along their own path by a deterministic declutter + // step until their boxes clear neighbours and the mask. + // These are the ONLY placement knobs — no per-guide hand-tuning. + // labelArcFraction is the fraction of each contour's own arc length + // (measured from the hidden convergence) at which its number anchors, + // so short low-SWR contours and long high-SWR contours are labeled in + // the same relative near-the-outer-end region. + double labelArcFraction{0.88}; + double labelDeclutterStep{3.0}; + double labelBoxPadding{2.0}; }; struct NeedleStyle { @@ -289,25 +330,46 @@ class CrossNeedleMeterGeometry { double forwardAngle(double forwardWatts, double rangeMultiplier) const; double reflectedAngle(double reflectedWatts, double rangeMultiplier) const; + static double angleForValue(const Scale &scale, double value); + // Bernstein movement angle for an already-normalized power. Not clamped to + // [0, 1]: SWR-contour construction passes slightly-over-unity values to + // extend a movement a little past full scale. Live needles/ticks always + // pass clamped values via angleForValue. + static double angleForNormalizedPower(const Scale &scale, double normalizedPower); QPointF forwardTip(double forwardWatts, double rangeMultiplier) const; QPointF reflectedTip(double reflectedWatts, double rangeMultiplier) const; QPointF needleIntersection(double forwardWatts, double reflectedWatts, double rangeMultiplier) const; QPainterPath swrGuidePath(const SwrGuide &guide) const; - QPointF swrGuideLabelCenter(const SwrGuide &guide) const; + QPointF swrGuideLabelCenter(const SwrGuide &guide, const QFont &labelFont) const; + QPointF swrGuideUpperEndpoint(const SwrGuide &guide) const; + QPointF powerReadingsAtIntersection(const QPointF &point, + double rangeMultiplier = 1.0) const; double distanceToGuide(const QPointF &point, const SwrGuide &guide) const; QString nearestGuideLabel(const QPointF &point, double *distance = nullptr) const; static double reflectedPowerWatts(double forwardWatts, double swr); static double swrFromPowers(double forwardWatts, double reflectedWatts); static double rangeMultiplierFor(int maxWatts, bool amplifierActive); + static double printedAngleForIndex(const Scale &scale, int index); private: - static double interpolate(const Scale &scale, double value); + static double inverseInterpolate(const Scale &scale, double angleRadians); static QPointF pointOnScale(const Scale &scale, double angleRadians); static QPointF lineIntersection(const QPointF &firstOrigin, const QPointF &firstTip, const QPointF &secondOrigin, const QPointF &secondTip); + // Deterministic derived placement of all SWR numbers, in guide order. + // Result is memoized by the exact rendered font: the geometry is immutable + // after load, while a widget or application font change must recompute the + // collision layout before the static face is rebuilt. + QVector swrLabelCenters(const QFont &labelFont) const; + mutable QVector swrLabelCenterCache; + mutable QString swrLabelCenterCacheFontKey; + mutable QString swrLabelPlacementError; + QRectF swrLabelBox(const QPointF ¢er, const SwrGuide &guide, + const QFont &labelFont) const; + double maskBoundaryY(double x) const; }; } // namespace AetherSDR diff --git a/src/gui/CrossNeedleMeterSettings.cpp b/src/gui/CrossNeedleMeterSettings.cpp index 53ffe7599..3ff554229 100644 --- a/src/gui/CrossNeedleMeterSettings.cpp +++ b/src/gui/CrossNeedleMeterSettings.cpp @@ -18,6 +18,7 @@ QString encode(const Snapshot& settings) QJsonObject root; root.insert(QStringLiteral("version"), kVersion); root.insert(QStringLiteral("faceTheme"), normalizeTheme(settings.faceTheme)); + root.insert(QStringLiteral("showRange"), settings.showRange); return QString::fromUtf8(QJsonDocument(root).toJson(QJsonDocument::Compact)); } @@ -40,7 +41,8 @@ Snapshot decode(const QByteArray& encoded, QString* error) } const QJsonObject root = document.object(); - if (root.value(QStringLiteral("version")).toInt() != kVersion) { + const int version = root.value(QStringLiteral("version")).toInt(); + if (version != 1 && version != kVersion) { if (error) { *error = QStringLiteral("unsupported CrossNeedleMeter settings version"); } @@ -49,6 +51,10 @@ Snapshot decode(const QByteArray& encoded, QString* error) settings.faceTheme = normalizeTheme( root.value(QStringLiteral("faceTheme")).toString()); + if (version >= 2) { + settings.showRange = + root.value(QStringLiteral("showRange")).toBool(true); + } return settings; } diff --git a/src/gui/CrossNeedleMeterSettings.h b/src/gui/CrossNeedleMeterSettings.h index 9965a5beb..f4530dbe3 100644 --- a/src/gui/CrossNeedleMeterSettings.h +++ b/src/gui/CrossNeedleMeterSettings.h @@ -5,7 +5,7 @@ namespace AetherSDR::CrossNeedleMeterSettingsCodec { -inline constexpr int kVersion = 1; +inline constexpr int kVersion = 2; inline const QString kSettingsKey = QStringLiteral("CrossNeedleMeter"); inline const QString kClassicTheme = QStringLiteral("classic-warm"); inline const QString kUplightTheme = QStringLiteral("dark-room-uplight"); @@ -13,6 +13,7 @@ inline const QString kDarkTheme = QStringLiteral("graphite-dark"); struct Snapshot { QString faceTheme{kUplightTheme}; + bool showRange{true}; }; QString normalizeTheme(const QString& theme); diff --git a/src/gui/CrossNeedleMeterWidget.cpp b/src/gui/CrossNeedleMeterWidget.cpp index d1570c9ce..51a2c5bb4 100644 --- a/src/gui/CrossNeedleMeterWidget.cpp +++ b/src/gui/CrossNeedleMeterWidget.cpp @@ -6,7 +6,6 @@ #include #include #include -#include #include #include #include @@ -76,56 +75,6 @@ QPainterPath sampledArc(const QPointF ¢er, double radius, double startRadian return path; } -const QImage &paperGrainTexture() { - // Keep the texture near the meter's nominal on-screen size. The previous - // 750 x 500 field averaged several noise samples into every applet pixel, - // making its grain disappear when the design was reduced to 260 x 173. - // Two correlated noise bands and sparse flecks read as paper/paint fibres - // without introducing a repeating raster asset. - static const QImage texture = []() { - // Roughly 1.4 logical applet pixels per texel at the nominal size: - // broad enough to remain visible on a Retina capture, fine enough not - // to look like discrete digital blocks. - QImage image(180, 120, QImage::Format_RGB32); - quint32 state = 0x6d2b79f5U; - const auto nextNoise = [&state]() { - state ^= state << 13U; - state ^= state >> 17U; - state ^= state << 5U; - return state; - }; - for (int y = 0; y < image.height(); ++y) { - const int rowBias = static_cast((nextNoise() >> 28U) & 0x0fU) - 8; - int horizontalFibre = 0; - QRgb *line = reinterpret_cast(image.scanLine(y)); - for (int x = 0; x < image.width(); ++x) { - const quint32 sample = nextNoise(); - const int fine = static_cast((sample >> 26U) & 0x3fU) - 32; - horizontalFibre = (7 * horizontalFibre + fine) / 8; - const int fleck = (sample & 0xffU) < 6U - ? (((sample >> 8U) & 1U) == 0U ? -34 : 34) - : 0; - const int value = std::clamp( - 128 + rowBias + fine + 2 * horizontalFibre + fleck, 58, 198); - line[x] = qRgb(value, value, value); - } - } - return image; - }(); - return texture; -} - -void drawPaperGrain(QPainter &painter, const QRectF &face, double opacity) { - painter.save(); - painter.setOpacity(opacity); - // Overlay keeps a mid-grey texture luminance-neutral while giving its - // darker and lighter fibres enough contrast to survive at 260 px wide. - painter.setCompositionMode(QPainter::CompositionMode_Overlay); - painter.setRenderHint(QPainter::SmoothPixmapTransform, true); - painter.drawImage(face, paperGrainTexture()); - painter.restore(); -} - } // namespace CrossNeedleMeterWidget::CrossNeedleMeterWidget(QWidget *parent) @@ -145,6 +94,14 @@ CrossNeedleMeterWidget::CrossNeedleMeterWidget(QWidget *parent) m_geometry = CrossNeedleMeterGeometry::fallback(); } + QString themeError; + m_faceThemes = AnalogMeterFaceThemeCatalog::loadResource(&themeError); + if (!m_faceThemes.isValid()) { + qCWarning(lcCrossNeedleMeter).noquote() + << "CrossNeedleMeterWidget: using fallback face themes:" << themeError; + m_faceThemes = AnalogMeterFaceThemeCatalog::fallback(); + } + setObjectName(QStringLiteral("crossNeedleMeter")); setAccessibleName(tr("Cross-needle power and SWR meter")); setAccessibleDescription(tr("Transmit meter with separate forward and reflected power needles; " @@ -190,6 +147,18 @@ QString CrossNeedleMeterWidget::faceThemeId() const { return QStringLiteral("classic-warm"); } +AnalogMeterFaceTheme CrossNeedleMeterWidget::analogFaceTheme() const { + switch (m_faceTheme) { + case FaceTheme::ClassicWarm: + return AnalogMeterFaceTheme::ClassicWarm; + case FaceTheme::DarkRoomUplight: + return AnalogMeterFaceTheme::DarkRoomUplight; + case FaceTheme::GraphiteDark: + return AnalogMeterFaceTheme::GraphiteDark; + } + return AnalogMeterFaceTheme::ClassicWarm; +} + void CrossNeedleMeterWidget::setFaceTheme(FaceTheme theme) { if (m_faceTheme == theme) { return; @@ -200,6 +169,16 @@ void CrossNeedleMeterWidget::setFaceTheme(FaceTheme theme) { update(); } +void CrossNeedleMeterWidget::setRangeLegendVisible(bool visible) { + if (m_rangeLegendVisible == visible) { + return; + } + m_rangeLegendVisible = visible; + m_cacheValid = false; + publishAutomationProperties(); + update(); +} + QString CrossNeedleMeterWidget::accessibleValueText() const { if (!m_transmitting && !m_automationFixture) { return tr("Receive, forward and reflected needles parked at zero"); @@ -388,19 +367,8 @@ void CrossNeedleMeterWidget::drawScale(QPainter &painter, const CrossNeedleMeterGeometry::Scale &scale, const CrossNeedleMeterGeometry::Title &title) const { const CrossNeedleMeterGeometry::ScaleStyle &style = m_geometry.scaleStyle; - const CrossNeedleMeterGeometry::DarkTheme &dark = m_geometry.darkTheme; - const bool graphiteDark = m_faceTheme == FaceTheme::GraphiteDark; - const QColor outer = graphiteDark ? dark.scaleOuter : style.outer; - const QColor separator = graphiteDark - ? dark.scaleSeparator - : (m_faceTheme == FaceTheme::DarkRoomUplight - ? m_geometry.uplightGradient.scaleSeparator - : style.separator); - const QColor calibration = graphiteDark ? dark.scaleCalibration : style.calibration; - const QColor inner = graphiteDark ? dark.scaleInner : style.inner; - const QColor majorTick = graphiteDark ? dark.majorTick : style.majorTick; - const QColor minorTick = graphiteDark ? dark.minorTick : style.minorTick; - const QColor text = graphiteDark ? dark.text : style.text; + const AnalogMeterFaceThemeCatalog::Palette &colors = + m_faceThemes.palette(analogFaceTheme()); // drawFace() leaves the face-coloured brush active. An open QPainterPath // is implicitly closed for filling, so retaining that brush would paint a @@ -413,31 +381,36 @@ void CrossNeedleMeterWidget::drawScale(QPainter &painter, // slate inner rule without relying on raster artwork or changing an arc. if (m_faceTheme == FaceTheme::ClassicWarm) { painter.setPen( - QPen(style.ribbon, style.ribbonWidth, Qt::SolidLine, Qt::FlatCap, Qt::RoundJoin)); + QPen(colors.ribbon, style.ribbonWidth, Qt::SolidLine, Qt::FlatCap, Qt::RoundJoin)); painter.drawPath(sampledArc(scale.center, scale.radius - style.ribbonInset, scale.startRadians, scale.endRadians)); } - painter.setPen(QPen(outer, style.outerWidth, Qt::SolidLine, Qt::FlatCap, Qt::RoundJoin)); + painter.setPen(QPen(colors.scaleOuter, style.outerWidth, Qt::SolidLine, Qt::FlatCap, + Qt::RoundJoin)); painter.drawPath(sampledArc(scale.center, scale.radius, scale.startRadians, scale.endRadians)); painter.setPen( - QPen(separator, style.separatorWidth, Qt::SolidLine, Qt::FlatCap, Qt::RoundJoin)); + QPen(colors.scaleSeparator, style.separatorWidth, Qt::SolidLine, Qt::FlatCap, + Qt::RoundJoin)); painter.drawPath(sampledArc(scale.center, scale.radius - style.separatorInset, scale.startRadians, scale.endRadians)); painter.setPen( - QPen(calibration, style.calibrationWidth, Qt::SolidLine, Qt::FlatCap, Qt::RoundJoin)); + QPen(colors.scaleCalibration, style.calibrationWidth, Qt::SolidLine, Qt::FlatCap, + Qt::RoundJoin)); painter.drawPath(sampledArc(scale.center, scale.radius - style.calibrationInset, scale.startRadians, scale.endRadians)); - painter.setPen(QPen(inner, style.innerWidth, Qt::SolidLine, Qt::FlatCap, Qt::RoundJoin)); + painter.setPen(QPen(colors.scaleInner, style.innerWidth, Qt::SolidLine, Qt::FlatCap, + Qt::RoundJoin)); painter.drawPath(sampledArc(scale.center, scale.radius - style.innerInset, scale.startRadians, scale.endRadians)); - const QPen minorPen(minorTick, style.minorTickWidth, Qt::SolidLine, Qt::SquareCap); + const QPen minorPen(colors.minorTick, style.minorTickWidth, Qt::SolidLine, Qt::SquareCap); for (int i = 0; i + 1 < scale.anglesRadians.size(); ++i) { - const double first = scale.anglesRadians[i]; - const double second = scale.anglesRadians[i + 1]; + const double firstValue = scale.values[i]; + const double secondValue = scale.values[i + 1]; for (int subdivision = 1; subdivision < scale.minorSubdivisions; ++subdivision) { const double fraction = static_cast(subdivision) / scale.minorSubdivisions; - const double angle = first + (second - first) * fraction; + const double value = std::lerp(firstValue, secondValue, fraction); + const double angle = CrossNeedleMeterGeometry::angleForValue(scale, value); const QPointF radial(std::cos(angle), std::sin(angle)); const QPointF point = scale.center + radial * scale.radius; painter.setPen(minorPen); @@ -449,15 +422,15 @@ void CrossNeedleMeterWidget::drawScale(QPainter &painter, numberFont.setPixelSize(m_geometry.typography.scaleNumberPixels); numberFont.setWeight(QFont::Medium); painter.setFont(numberFont); - painter.setPen(QPen(majorTick, style.majorTickWidth, Qt::SolidLine, Qt::SquareCap)); + painter.setPen(QPen(colors.majorTick, style.majorTickWidth, Qt::SolidLine, Qt::SquareCap)); for (int i = 0; i < scale.anglesRadians.size(); ++i) { - const double angle = scale.anglesRadians[i]; + const double angle = CrossNeedleMeterGeometry::printedAngleForIndex(scale, i); const QPointF radial(std::cos(angle), std::sin(angle)); const QPointF point = scale.center + radial * scale.radius; painter.drawLine(point - radial * 20.0, point + radial * 14.0); if (!scale.labels[i].isEmpty()) { painter.save(); - painter.setPen(text); + painter.setPen(colors.text); drawCenteredText(painter, point + radial * scale.labelOffset, scale.labels[i]); painter.restore(); } @@ -467,26 +440,43 @@ void CrossNeedleMeterWidget::drawScale(QPainter &painter, titleFont.setPixelSize(m_geometry.typography.sideTitlePixels); titleFont.setBold(true); painter.setFont(titleFont); - painter.setPen(text); + painter.setPen(colors.text); drawRotatedText(painter, title.center, title.text, title.rotationDegrees); } void CrossNeedleMeterWidget::drawSwrGuides(QPainter &painter) const { - const bool graphiteDark = m_faceTheme == FaceTheme::GraphiteDark; - const QColor guideColor = - graphiteDark ? m_geometry.darkTheme.swrGuide : m_geometry.swrStyle.guide; - const QColor labelColor = - graphiteDark ? m_geometry.darkTheme.swrLabel : m_geometry.swrStyle.label; - painter.setPen(QPen(guideColor, m_geometry.swrStyle.guideWidth, Qt::SolidLine, Qt::RoundCap, + const AnalogMeterFaceThemeCatalog::Palette &colors = + m_faceThemes.palette(analogFaceTheme()); + painter.setPen(QPen(colors.swrGuide, m_geometry.swrStyle.guideWidth, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin)); + // Trim the leading (lower) end of every contour to sit a small gap above + // the mask, like the real meter face: clip guide drawing to the region + // above the mask boundary raised by maskGap. Labels below are unaffected. + painter.save(); + const QVector &maskEdge = m_geometry.mask.boundary; + const double maskGap = m_geometry.swrStyle.maskGap; + if (maskGap > 0.0 && maskEdge.size() >= 2) { + QPainterPath above; + above.moveTo(0.0, 0.0); + above.lineTo(m_geometry.canvasWidth, 0.0); + above.lineTo(m_geometry.canvasWidth, maskEdge.last().y() - maskGap); + for (int i = maskEdge.size() - 1; i >= 0; --i) { + above.lineTo(maskEdge[i].x(), maskEdge[i].y() - maskGap); + } + above.lineTo(0.0, maskEdge.first().y() - maskGap); + above.closeSubpath(); + painter.setClipPath(above, Qt::IntersectClip); + } for (const CrossNeedleMeterGeometry::SwrGuide &guide : m_geometry.swrGuides) { painter.drawPath(m_geometry.swrGuidePath(guide)); } + painter.restore(); QFont labelFont = font(); labelFont.setPixelSize(m_geometry.typography.swrNumberPixels); labelFont.setBold(true); painter.setFont(labelFont); + const QFontMetricsF metrics(labelFont); for (const CrossNeedleMeterGeometry::SwrGuide &guide : m_geometry.swrGuides) { if (guide.displayLabel.isEmpty()) { continue; @@ -494,10 +484,11 @@ void CrossNeedleMeterWidget::drawSwrGuides(QPainter &painter) const { const QString display = guide.displayLabel == QStringLiteral("infinity") ? QString::fromUtf8("\xe2\x88\x9e") : guide.displayLabel; - const QFontMetricsF metrics(labelFont); const QRectF textRect = metrics.boundingRect(display).adjusted(-3.0, -2.0, 3.0, 2.0); + const QPointF labelCenter = + m_geometry.swrGuideLabelCenter(guide, labelFont); QRectF background = textRect; - background.moveCenter(m_geometry.swrGuideLabelCenter(guide)); + background.moveCenter(labelCenter); const CrossNeedleMeterGeometry::Frame &frame = m_geometry.frame; const QRectF face(frame.faceInset, frame.faceInset, m_geometry.canvasWidth - 2.0 * frame.faceInset, @@ -506,115 +497,13 @@ void CrossNeedleMeterWidget::drawSwrGuides(QPainter &painter) const { painter.setClipRect(background); drawFaceBackground(painter, face); painter.restore(); - painter.setPen(labelColor); - drawCenteredText(painter, m_geometry.swrGuideLabelCenter(guide), display); + painter.setPen(colors.swrLabel); + drawCenteredText(painter, labelCenter, display); } } void CrossNeedleMeterWidget::drawFaceBackground(QPainter &painter, const QRectF &face) const { - if (m_faceTheme == FaceTheme::GraphiteDark) { - const CrossNeedleMeterGeometry::DarkTheme &dark = m_geometry.darkTheme; - - QLinearGradient card(face.topLeft(), face.bottomLeft()); - card.setColorAt(0.0, dark.top); - card.setColorAt(dark.middleStop, dark.middle); - card.setColorAt(1.0, dark.bottom); - painter.fillRect(face, card); - - QRadialGradient ambient(dark.ambientCenter, dark.ambientRadius); - ambient.setColorAt(0.0, dark.ambientInner); - ambient.setColorAt(1.0, dark.ambientOuter); - painter.fillRect(face, ambient); - - painter.save(); - painter.setCompositionMode(QPainter::CompositionMode_Screen); - QRadialGradient glow(dark.glowCenter, dark.glowRadius); - glow.setColorAt(0.0, dark.glowInner); - glow.setColorAt(dark.glowMiddleStop, dark.glowMiddle); - glow.setColorAt(1.0, dark.glowOuter); - painter.fillRect(face, glow); - painter.restore(); - - QColor clearEdge = dark.vignetteEdge; - clearEdge.setAlpha(0); - QRadialGradient vignette(dark.vignetteCenter, dark.vignetteRadius); - vignette.setColorAt(0.0, clearEdge); - vignette.setColorAt(dark.vignetteClearStop, clearEdge); - vignette.setColorAt(1.0, dark.vignetteEdge); - painter.fillRect(face, vignette); - - drawPaperGrain(painter, face, dark.paperGrainOpacity); - return; - } - - if (m_faceTheme == FaceTheme::DarkRoomUplight) { - const CrossNeedleMeterGeometry::UplightGradient &light = m_geometry.uplightGradient; - - // The theme is deliberately composed from ordinary QPainter layers: - // low ambient exposure, a broad lamp halo, a concentrated bulb glow, - // a small paper-diffusion bloom, then a symmetric edge vignette. The - // two inner layers use Screen blending to behave like emitted light - // passing through the card rather than opaque orange paint. - QLinearGradient ambient(face.topLeft(), face.bottomLeft()); - ambient.setColorAt(0.0, light.top); - ambient.setColorAt(light.middleStop, light.middle); - ambient.setColorAt(1.0, light.bottom); - painter.fillRect(face, ambient); - - QRadialGradient halo(light.haloCenter, light.haloRadius); - halo.setColorAt(0.0, light.haloInner); - halo.setColorAt(light.haloMiddleStop, light.haloMiddle); - halo.setColorAt(light.haloShoulderStop, light.haloShoulder); - halo.setColorAt(1.0, light.haloOuter); - painter.fillRect(face, halo); - - painter.save(); - painter.setCompositionMode(QPainter::CompositionMode_Screen); - QRadialGradient hotspot(light.hotspotCenter, light.hotspotRadius); - hotspot.setColorAt(0.0, light.hotspotInner); - hotspot.setColorAt(light.hotspotMiddleStop, light.hotspotMiddle); - hotspot.setColorAt(1.0, light.hotspotOuter); - painter.fillRect(face, hotspot); - - QRadialGradient bloom(light.bloomCenter, light.bloomRadius); - bloom.setColorAt(0.0, light.bloomInner); - bloom.setColorAt(light.bloomMiddleStop, light.bloomMiddle); - bloom.setColorAt(1.0, light.bloomOuter); - painter.fillRect(face, bloom); - painter.restore(); - - QColor clearEdge = light.vignetteEdge; - clearEdge.setAlpha(0); - QRadialGradient vignette(light.vignetteCenter, light.vignetteRadius); - vignette.setColorAt(0.0, clearEdge); - vignette.setColorAt(light.vignetteClearStop, clearEdge); - vignette.setColorAt(1.0, light.vignetteEdge); - painter.fillRect(face, vignette); - - drawPaperGrain(painter, face, light.paperGrainOpacity); - return; - } - - const CrossNeedleMeterGeometry::FaceGradient &material = m_geometry.faceGradient; - - QLinearGradient base(face.topLeft(), face.bottomLeft()); - base.setColorAt(0.0, material.top); - base.setColorAt(material.middleStop, material.middle); - base.setColorAt(1.0, material.bottom); - painter.fillRect(face, base); - - QRadialGradient glow(material.glowCenter, material.glowRadius); - glow.setColorAt(0.0, material.glowInner); - glow.setColorAt(1.0, material.glowOuter); - painter.fillRect(face, glow); - - QColor clearEdge = material.vignetteEdge; - clearEdge.setAlpha(0); - QRadialGradient vignette(material.vignetteCenter, material.vignetteRadius); - vignette.setColorAt(0.0, clearEdge); - vignette.setColorAt(material.vignetteClearStop, clearEdge); - vignette.setColorAt(1.0, material.vignetteEdge); - painter.fillRect(face, vignette); + m_faceThemes.drawBackground(painter, face, analogFaceTheme()); } void CrossNeedleMeterWidget::drawFace(QPainter &painter) const { @@ -664,20 +553,23 @@ void CrossNeedleMeterWidget::drawFace(QPainter &painter) const { drawScale(painter, m_geometry.reflectedScale, m_geometry.reflectedTitle); } - QFont rangeFont = font(); - rangeFont.setPixelSize(m_geometry.typography.rangePixels); - rangeFont.setBold(true); - painter.setFont(rangeFont); - painter.setPen(m_faceTheme == FaceTheme::GraphiteDark ? m_geometry.darkTheme.rangeText - : QColor(54, 61, 66)); - drawCenteredMultilineText(painter, m_geometry.rangeLabelCenter, m_geometry.rangeLabel); + const AnalogMeterFaceThemeCatalog::Palette &colors = + m_faceThemes.palette(analogFaceTheme()); + if (m_rangeLegendVisible) { + QFont rangeFont = font(); + rangeFont.setPixelSize(m_geometry.typography.rangePixels); + rangeFont.setBold(true); + painter.setFont(rangeFont); + painter.setPen(colors.secondaryText); + drawCenteredMultilineText(painter, m_geometry.rangeLabelCenter, + m_geometry.rangeLabel); + } QFont unitFont = font(); unitFont.setPixelSize(m_geometry.typography.unitPixels); unitFont.setBold(true); painter.setFont(unitFont); - painter.setPen(m_faceTheme == FaceTheme::GraphiteDark ? m_geometry.darkTheme.text - : m_geometry.scaleStyle.text); + painter.setPen(colors.text); drawCenteredText(painter, m_geometry.forwardUnitCenter, QStringLiteral("(W)")); drawCenteredText(painter, m_geometry.reflectedUnitCenter, QStringLiteral("(W)")); @@ -692,28 +584,25 @@ void CrossNeedleMeterWidget::drawFace(QPainter &painter) const { } void CrossNeedleMeterWidget::drawLowerMask(QPainter &painter) const { - const bool graphiteDark = m_faceTheme == FaceTheme::GraphiteDark; - const QColor fill = graphiteDark ? m_geometry.darkTheme.maskFill : m_geometry.mask.fill; - const QColor edge = graphiteDark ? m_geometry.darkTheme.maskEdge : m_geometry.mask.edge; - const QColor text = graphiteDark ? m_geometry.darkTheme.maskText : m_geometry.mask.text; - QPolygonF polygon; - polygon.append(QPointF(m_geometry.mask.boundary.first().x(), m_geometry.mask.bottomY)); - for (const QPointF &point : m_geometry.mask.boundary) { - polygon.append(point); - } - polygon.append(QPointF(m_geometry.mask.boundary.last().x(), m_geometry.mask.bottomY)); + const AnalogMeterFaceThemeCatalog::Palette &colors = + m_faceThemes.palette(analogFaceTheme()); + const CrossNeedleMeterGeometry::Frame &frame = m_geometry.frame; + const QRectF face(frame.faceInset, frame.faceInset, + m_geometry.canvasWidth - 2.0 * frame.faceInset, + m_geometry.canvasHeight - 2.0 * frame.faceInset); + const QVector boundary = m_faceThemes.lowerMaskBoundary(face); painter.setPen(Qt::NoPen); - painter.setBrush(fill); - painter.drawPolygon(polygon); + painter.setBrush(colors.maskFill); + painter.drawPath(m_faceThemes.lowerMaskPath(face)); - painter.setPen(QPen(edge, 2.0, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin)); - painter.drawPolyline(QPolygonF(m_geometry.mask.boundary)); + painter.setPen(QPen(colors.maskEdge, 2.0, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin)); + painter.drawPolyline(QPolygonF(boundary)); QFont labelFont = font(); labelFont.setPixelSize(m_geometry.typography.maskLabelPixels); labelFont.setBold(true); painter.setFont(labelFont); - painter.setPen(text); + painter.setPen(colors.maskText); drawCenteredText(painter, m_geometry.mask.labelCenter, m_geometry.mask.label); } @@ -750,21 +639,21 @@ void CrossNeedleMeterWidget::drawNeedles(QPainter &painter) const { painter.save(); painter.setClipPath(clip); - const bool graphiteDark = m_faceTheme == FaceTheme::GraphiteDark; + const AnalogMeterFaceThemeCatalog::Palette &colors = + m_faceThemes.palette(analogFaceTheme()); const CrossNeedleMeterGeometry::NeedleStyle &style = m_geometry.needleStyle; - const QColor shadow = graphiteDark ? m_geometry.darkTheme.needleShadow : style.shadow; - const QColor line = graphiteDark ? m_geometry.darkTheme.needle : style.line; - const QColor edge = graphiteDark ? m_geometry.darkTheme.needleEdge : style.edge; - const QColor highlight = - graphiteDark ? m_geometry.darkTheme.needleHighlight : style.highlight; - const auto drawMovement = [&painter, &style, shadow, line, edge, highlight]( + const QColor shadow = colors.needleShadow; + const QColor line = colors.needle; + const QColor edge = colors.needleEdge; + const QColor highlight = colors.needleHighlight; + const auto drawMovement = [&painter, &style, &colors, shadow, line, edge, highlight]( const QPointF &pivot, const QPointF &tip) { // Build physical depth from code-drawn material layers. Only the soft // shadows and sub-pixel edge reflections are displaced; the body is // always painted on the calibrated pivot-to-tip ray. The cast shadows // remain continuous over the printed scale, as they would on a // physical meter face beneath an elevated needle. - painter.setPen(QPen(style.softShadow, style.softShadowWidth, Qt::SolidLine, + painter.setPen(QPen(colors.needleSoftShadow, style.softShadowWidth, Qt::SolidLine, Qt::RoundCap)); painter.drawLine(pivot + style.softShadowOffset, tip + style.softShadowOffset); painter.setPen(QPen(shadow, style.shadowWidth, Qt::SolidLine, Qt::RoundCap)); @@ -874,6 +763,7 @@ void CrossNeedleMeterWidget::publishAutomationProperties() { m_reflectedPowerMeasured ? QStringLiteral("measured") : QStringLiteral("derived")); setProperty("swr", m_swr); setProperty("rangeMultiplier", m_rangeMultiplier); + setProperty("rangeLegendVisible", m_rangeLegendVisible); setProperty("transmitting", m_transmitting); setProperty("effectiveActive", m_transmitting || m_automationFixture); setProperty("automationFixture", m_automationFixture); diff --git a/src/gui/CrossNeedleMeterWidget.h b/src/gui/CrossNeedleMeterWidget.h index f6e0d1d73..5c00ae627 100644 --- a/src/gui/CrossNeedleMeterWidget.h +++ b/src/gui/CrossNeedleMeterWidget.h @@ -1,5 +1,6 @@ #pragma once +#include "AnalogMeterFaceTheme.h" #include "CrossNeedleMeterGeometry.h" #include "MeterSmoother.h" @@ -43,6 +44,7 @@ class CrossNeedleMeterWidget : public QWidget { double rangeMultiplier() const { return m_rangeMultiplier; } bool isTransmitting() const { return m_transmitting; } bool reflectedPowerMeasured() const { return m_reflectedPowerMeasured; } + bool rangeLegendVisible() const { return m_rangeLegendVisible; } FaceTheme faceTheme() const { return m_faceTheme; } QString faceThemeId() const; const CrossNeedleMeterGeometry &geometry() const { return m_geometry; } @@ -54,6 +56,7 @@ class CrossNeedleMeterWidget : public QWidget { void setTransmitting(bool transmitting); void setPowerScale(int maxWatts, bool amplifierActive); void setFaceTheme(FaceTheme theme); + void setRangeLegendVisible(bool visible); // Test fixture used only by the in-process automation bridge. AppletPanel // exposes it only when AETHER_AUTOMATION is set; production UI cannot call @@ -85,6 +88,7 @@ class CrossNeedleMeterWidget : public QWidget { void drawCenteredText(QPainter &painter, const QPointF ¢er, const QString &text) const; void drawCenteredMultilineText(QPainter &painter, const QPointF ¢er, const QString &text) const; + AnalogMeterFaceTheme analogFaceTheme() const; void drawRotatedText(QPainter &painter, const QPointF ¢er, const QString &text, double degrees) const; void updateTargets(bool snap); @@ -92,6 +96,7 @@ class CrossNeedleMeterWidget : public QWidget { void publishAutomationProperties(); CrossNeedleMeterGeometry m_geometry; + AnalogMeterFaceThemeCatalog m_faceThemes; MeterSmoother m_forwardSmoother; MeterSmoother m_reflectedSmoother; QTimer m_animationTimer; @@ -109,6 +114,7 @@ class CrossNeedleMeterWidget : public QWidget { double m_rangeMultiplier{10.0}; bool m_transmitting{false}; bool m_reflectedPowerMeasured{false}; + bool m_rangeLegendVisible{true}; bool m_automationFixture{false}; FaceTheme m_faceTheme{FaceTheme::DarkRoomUplight}; }; diff --git a/src/gui/MainWindow_Wiring.cpp b/src/gui/MainWindow_Wiring.cpp index a7ce2f138..920869ac8 100644 --- a/src/gui/MainWindow_Wiring.cpp +++ b/src/gui/MainWindow_Wiring.cpp @@ -4270,7 +4270,8 @@ void MainWindow::wireMeters() this, [this](float fwd, float swr) { if (m_radioModel.amplifier().present() && m_radioModel.amplifier().operate()) return; - m_appletPanel->setStandardMeterTxValues(fwd, swr); + m_appletPanel->setStandardRadioMeterTxValues( + fwd, m_radioModel.meterModel().fwdPowerInstant(), swr); #ifdef HAVE_HIDAPI m_tmate2TxWatts = fwd; if (m_radioModel.transmitModel().isTransmitting()) { diff --git a/src/gui/RadioSwrValidityFilter.cpp b/src/gui/RadioSwrValidityFilter.cpp new file mode 100644 index 000000000..a6edbc707 --- /dev/null +++ b/src/gui/RadioSwrValidityFilter.cpp @@ -0,0 +1,150 @@ +#include "RadioSwrValidityFilter.h" + +#include +#include + +namespace AetherSDR { + +RadioSwrValidityFilter::Result RadioSwrValidityFilter::update( + float forwardPowerInstant, float rawSwr, + qint64 timestampMs, float maximumDisplayedSwr) +{ + const qint64 boundedTimestampMs = std::max(timestampMs, 0); + const qint64 effectiveTimestampMs = + m_lastTimestampMs >= 0 + ? std::max(boundedTimestampMs, m_lastTimestampMs) + : boundedTimestampMs; + if (m_lastTimestampMs >= 0) { + const double elapsedMs = + static_cast(effectiveTimestampMs - m_lastTimestampMs); + const double release = + std::exp2(-elapsedMs / kEnvelopeHalfLifeMs); + m_forwardEnvelopeWatts = + static_cast(m_forwardEnvelopeWatts * release); + } + m_lastTimestampMs = effectiveTimestampMs; + + const bool finiteForward = std::isfinite(forwardPowerInstant); + if (finiteForward && forwardPowerInstant > 0.0f) { + m_forwardEnvelopeWatts = + std::max(m_forwardEnvelopeWatts, forwardPowerInstant); + } + + const float minimumForwardPower = std::max( + kMinimumForwardPowerWatts, + m_forwardEnvelopeWatts * kEnvelopeThresholdFraction); + const bool hasAbsolutePower = + finiteForward && forwardPowerInstant >= kMinimumForwardPowerWatts; + const bool hasMeaningfulPower = + hasAbsolutePower && forwardPowerInstant >= minimumForwardPower; + bool held = false; + + if (!std::isfinite(rawSwr)) { + held = m_hasReading; + m_lowSwrCandidateSamples = 0; + clearTimedConfirmations(); + } else if (!hasAbsolutePower) { + // No measurable carrier means there is no trustworthy SWR sample. + // In particular, do not let repeated no-carrier packets eventually + // satisfy either of the time-based confirmation windows. + held = m_hasReading; + m_lowSwrCandidateSamples = 0; + clearTimedConfirmations(); + } else if (rawSwr < 1.0f) { + // SWR below 1 is physically impossible and may be the radio's + // unavailable/over-range sentinel. A single corrupt sample must not + // flash a frightening full-scale warning, but persistence under any + // measurable carrier remains actionable even during power foldback. + if (confirmationElapsed(m_belowUnityStartedAtMs, + effectiveTimestampMs)) { + m_displayedSwr = maximumDisplayedSwr; + m_hasReading = true; + clearTimedConfirmations(); + } else { + held = m_hasReading; + m_lowPowerRecoveryStartedAtMs = -1; + } + m_lowSwrCandidateSamples = 0; + } else if (!hasMeaningfulPower) { + if (m_hasReading && rawSwr > m_displayedSwr) { + // Preserve worsening foldback warnings only while measurable RF is + // still present. Safety-significant rises remain immediate even + // while forward power is below the relative envelope threshold. + m_displayedSwr = rawSwr; + m_lowSwrCandidateSamples = 0; + clearTimedConfirmations(); + } else if (m_hasReading && rawSwr < m_displayedSwr) { + // A real re-match or ALC recovery can improve SWR while power is + // temporarily below the envelope threshold. Require one continuous, + // bounded wall-clock confirmation for every physically valid + // decrease, including near-unity readings, instead of holding the + // stale warning until the 750 ms envelope has completely released. + if (confirmationElapsed(m_lowPowerRecoveryStartedAtMs, + effectiveTimestampMs)) { + m_displayedSwr = rawSwr; + m_hasReading = true; + clearTimedConfirmations(); + } else { + held = true; + } + m_lowSwrCandidateSamples = 0; + } else { + held = m_hasReading; + m_lowSwrCandidateSamples = 0; + clearTimedConfirmations(); + } + } else if (rawSwr <= kLowSwrConfirmationMaximum + && m_hasReading + && m_displayedSwr > kLowSwrConfirmationMaximum) { + clearTimedConfirmations(); + ++m_lowSwrCandidateSamples; + if (m_lowSwrCandidateSamples >= kLowSwrConfirmationSamples) { + m_displayedSwr = rawSwr; + m_hasReading = true; + m_lowSwrCandidateSamples = 0; + } else { + held = true; + } + } else { + m_displayedSwr = rawSwr; + m_hasReading = true; + m_lowSwrCandidateSamples = 0; + clearTimedConfirmations(); + } + + return { + m_displayedSwr, + m_forwardEnvelopeWatts, + minimumForwardPower, + held, + m_hasReading, + }; +} + +bool RadioSwrValidityFilter::confirmationElapsed( + qint64& startedAtMs, qint64 timestampMs) +{ + if (startedAtMs < 0) { + startedAtMs = timestampMs; + return false; + } + return timestampMs - startedAtMs >= kTimedConfirmationMs; +} + +void RadioSwrValidityFilter::clearTimedConfirmations() +{ + m_lowPowerRecoveryStartedAtMs = -1; + m_belowUnityStartedAtMs = -1; +} + +void RadioSwrValidityFilter::reset() +{ + m_displayedSwr = 1.0f; + m_forwardEnvelopeWatts = 0.0f; + m_lastTimestampMs = -1; + clearTimedConfirmations(); + m_hasReading = false; + m_lowSwrCandidateSamples = 0; +} + +} // namespace AetherSDR diff --git a/src/gui/RadioSwrValidityFilter.h b/src/gui/RadioSwrValidityFilter.h new file mode 100644 index 000000000..d78e95b30 --- /dev/null +++ b/src/gui/RadioSwrValidityFilter.h @@ -0,0 +1,45 @@ +#pragma once + +#include + +namespace AetherSDR { + +// Validates the radio-native SWR stream against its instantaneous forward +// power. The forward-power envelope attacks immediately and releases with +// elapsed time, so brief modulation gaps cannot force SWR toward 1 while a +// sustained lower-power operating point eventually becomes authoritative. +class RadioSwrValidityFilter { +public: + struct Result { + float displayedSwr{1.0f}; + float forwardEnvelopeWatts{0.0f}; + float minimumForwardWatts{0.05f}; + bool held{false}; + bool hasReading{false}; + }; + + Result update(float forwardPowerInstant, float rawSwr, + qint64 timestampMs, float maximumDisplayedSwr); + void reset(); + +private: + static constexpr float kMinimumForwardPowerWatts = 0.05f; + static constexpr float kEnvelopeThresholdFraction = 0.20f; + static constexpr float kLowSwrConfirmationMaximum = 1.2f; + static constexpr int kLowSwrConfirmationSamples = 3; + static constexpr qint64 kTimedConfirmationMs = 250; + static constexpr double kEnvelopeHalfLifeMs = 750.0; + + static bool confirmationElapsed(qint64& startedAtMs, qint64 timestampMs); + void clearTimedConfirmations(); + + float m_displayedSwr{1.0f}; + float m_forwardEnvelopeWatts{0.0f}; + qint64 m_lastTimestampMs{-1}; + qint64 m_lowPowerRecoveryStartedAtMs{-1}; + qint64 m_belowUnityStartedAtMs{-1}; + bool m_hasReading{false}; + int m_lowSwrCandidateSamples{0}; +}; + +} // namespace AetherSDR diff --git a/src/gui/SMeterGeometry.cpp b/src/gui/SMeterGeometry.cpp new file mode 100644 index 000000000..b372ba1d4 --- /dev/null +++ b/src/gui/SMeterGeometry.cpp @@ -0,0 +1,778 @@ +#include "SMeterGeometry.h" + +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace AetherSDR { + +namespace { + +bool finite(double value) +{ + return std::isfinite(value); +} + +bool positive(double value) +{ + return finite(value) && value > 0.0; +} + +bool nonNegative(double value) +{ + return finite(value) && value >= 0.0; +} + +bool nearlyEqual(double first, double second, double tolerance = 1.0e-9) +{ + return finite(first) && finite(second) && std::abs(first - second) <= tolerance; +} + +bool parseFailure(QString& error, const QString& path, const QString& requirement) +{ + error = QStringLiteral("s-meter-v1.json: %1 must be %2").arg(path, requirement); + return false; +} + +QString childPath(const QString& parent, const QString& key) +{ + return parent.isEmpty() ? key : parent + QLatin1Char('.') + key; +} + +bool readObject(const QJsonObject& object, const QString& key, const QString& parentPath, + QJsonObject& output, QString& error) +{ + const QString path = childPath(parentPath, key); + const QJsonValue value = object.value(key); + if (!value.isObject()) { + return parseFailure(error, path, QStringLiteral("an object")); + } + output = value.toObject(); + return true; +} + +bool readNumberValue(const QJsonValue& value, const QString& path, double& output, + QString& error) +{ + if (!value.isDouble() || !finite(value.toDouble())) { + return parseFailure(error, path, QStringLiteral("a finite number")); + } + output = value.toDouble(); + return true; +} + +bool readNumber(const QJsonObject& object, const QString& key, const QString& parentPath, + double& output, QString& error) +{ + return readNumberValue(object.value(key), childPath(parentPath, key), output, error); +} + +bool readIntValue(const QJsonValue& value, const QString& path, int& output, QString& error) +{ + double number = 0.0; + if (!readNumberValue(value, path, number, error)) { + return false; + } + if (std::floor(number) != number || number < std::numeric_limits::min() + || number > std::numeric_limits::max()) { + return parseFailure(error, path, QStringLiteral("an integer")); + } + output = static_cast(number); + return true; +} + +bool readInt(const QJsonObject& object, const QString& key, const QString& parentPath, + int& output, QString& error) +{ + return readIntValue(object.value(key), childPath(parentPath, key), output, error); +} + +bool readBool(const QJsonObject& object, const QString& key, const QString& parentPath, + bool& output, QString& error) +{ + const QString path = childPath(parentPath, key); + const QJsonValue value = object.value(key); + if (!value.isBool()) { + return parseFailure(error, path, QStringLiteral("a boolean")); + } + output = value.toBool(); + return true; +} + +bool readString(const QJsonObject& object, const QString& key, const QString& parentPath, + QString& output, QString& error) +{ + const QString path = childPath(parentPath, key); + const QJsonValue value = object.value(key); + if (!value.isString()) { + return parseFailure(error, path, QStringLiteral("a string")); + } + output = value.toString(); + return true; +} + +bool readSize(const QJsonObject& object, const QString& key, const QString& parentPath, + QSize& output, QString& error) +{ + const QString path = childPath(parentPath, key); + const QJsonValue value = object.value(key); + if (!value.isArray()) { + return parseFailure(error, path, QStringLiteral("a two-integer array")); + } + const QJsonArray values = value.toArray(); + if (values.size() != 2) { + return parseFailure(error, path, QStringLiteral("a two-integer array")); + } + int width = 0; + int height = 0; + if (!readIntValue(values.at(0), path + QStringLiteral("[0]"), width, error) + || !readIntValue(values.at(1), path + QStringLiteral("[1]"), height, error)) { + return false; + } + output = QSize(width, height); + return true; +} + +bool readPoint(const QJsonObject& object, const QString& key, const QString& parentPath, + QPointF& output, QString& error) +{ + const QString path = childPath(parentPath, key); + const QJsonValue value = object.value(key); + if (!value.isArray()) { + return parseFailure(error, path, QStringLiteral("a two-number array")); + } + const QJsonArray values = value.toArray(); + if (values.size() != 2) { + return parseFailure(error, path, QStringLiteral("a two-number array")); + } + double x = 0.0; + double y = 0.0; + if (!readNumberValue(values.at(0), path + QStringLiteral("[0]"), x, error) + || !readNumberValue(values.at(1), path + QStringLiteral("[1]"), y, error)) { + return false; + } + output = QPointF(x, y); + return true; +} + +bool readTicks(const QJsonObject& object, const QString& key, const QString& parentPath, + QVector& output, QString& error) +{ + const QString path = childPath(parentPath, key); + const QJsonValue value = object.value(key); + if (!value.isArray()) { + return parseFailure(error, path, QStringLiteral("an array")); + } + + const QJsonArray values = value.toArray(); + output.clear(); + output.reserve(values.size()); + for (qsizetype index = 0; index < values.size(); ++index) { + const QString entryPath = path + QStringLiteral("[%1]").arg(index); + if (!values.at(index).isObject()) { + return parseFailure(error, entryPath, QStringLiteral("an object")); + } + const QJsonObject tickObject = values.at(index).toObject(); + SMeterGeometry::Tick tick; + if (!readNumber(tickObject, QStringLiteral("value"), entryPath, tick.value, error) + || !readString(tickObject, QStringLiteral("label"), entryPath, tick.label, error)) { + return false; + } + output.push_back(tick); + } + return true; +} + +bool readStaticScale(const QJsonObject& root, const QString& key, + SMeterGeometry::StaticScale& scale, QString& error) +{ + QJsonObject object; + if (!readObject(root, key, QString(), object, error)) { + return false; + } + return readNumber(object, QStringLiteral("minimum"), key, scale.minimum, error) + && readNumber(object, QStringLiteral("maximum"), key, scale.maximum, error) + && readBool(object, QStringLiteral("has_warning"), key, scale.hasWarning, error) + && readNumber(object, QStringLiteral("warning_start"), key, scale.warningStart, error) + && readTicks(object, QStringLiteral("ticks"), key, scale.ticks, error); +} + +SMeterGeometry rejectedGeometry(QString* error, const QString& message) +{ + if (error) { + *error = message; + } + return {}; +} + +bool validTicks(const QVector& ticks, double minimum, double maximum) +{ + if (ticks.isEmpty()) { + return false; + } + double previous = -std::numeric_limits::infinity(); + for (const SMeterGeometry::Tick& tick : ticks) { + if (!finite(tick.value) || tick.value < minimum || tick.value > maximum + || tick.value <= previous || tick.label.isEmpty()) { + return false; + } + previous = tick.value; + } + return true; +} + +} // namespace + +SMeterGeometry SMeterGeometry::loadResource(QString* error) +{ + QFile file(QStringLiteral(":/meterfaces/s-meter-v1.json")); + if (!file.open(QIODevice::ReadOnly)) { + if (error) { + *error = QStringLiteral("cannot open :/meterfaces/s-meter-v1.json"); + } + return {}; + } + return load(file, error); +} + +SMeterGeometry SMeterGeometry::load(QIODevice& device, QString* error) +{ + if (error) { + error->clear(); + } + + QJsonParseError parseError; + const QJsonDocument document = QJsonDocument::fromJson(device.readAll(), &parseError); + if (parseError.error != QJsonParseError::NoError || !document.isObject()) { + return rejectedGeometry( + error, QStringLiteral("invalid JSON: %1").arg(parseError.errorString())); + } + + const QJsonObject root = document.object(); + SMeterGeometry geometry; + QString fieldError; + QJsonObject sizing; + QJsonObject arc; + QJsonObject tickStyle; + QJsonObject rxScale; + QJsonObject needle; + QJsonObject pivot; + QJsonObject peakMarker; + QJsonObject peakHold; + QJsonObject readout; + + const bool fixedFieldsValid = + readInt(root, QStringLiteral("format_version"), QString(), geometry.formatVersion, + fieldError) + && readInt(root, QStringLiteral("design_version"), QString(), geometry.designVersion, + fieldError) + && readObject(root, QStringLiteral("sizing"), QString(), sizing, fieldError) + && readSize(sizing, QStringLiteral("preferred"), QStringLiteral("sizing"), + geometry.sizing.preferred, fieldError) + && readSize(sizing, QStringLiteral("minimum"), QStringLiteral("sizing"), + geometry.sizing.minimum, fieldError) + && readNumber(sizing, QStringLiteral("minimum_aspect_ratio"), + QStringLiteral("sizing"), geometry.sizing.minimumAspectRatio, + fieldError) + && readNumber(sizing, QStringLiteral("maximum_aspect_ratio"), + QStringLiteral("sizing"), geometry.sizing.maximumAspectRatio, + fieldError) + && readObject(root, QStringLiteral("arc"), QString(), arc, fieldError) + && readNumber(arc, QStringLiteral("center_x_width_factor"), QStringLiteral("arc"), + geometry.arc.centerXWidthFactor, fieldError) + && readNumber(arc, QStringLiteral("radius_width_factor"), QStringLiteral("arc"), + geometry.arc.radiusWidthFactor, fieldError) + && readNumber(arc, QStringLiteral("center_y_height_factor"), QStringLiteral("arc"), + geometry.arc.centerYHeightFactor, fieldError) + && readNumber(arc, QStringLiteral("start_degrees"), QStringLiteral("arc"), + geometry.arc.startDegrees, fieldError) + && readNumber(arc, QStringLiteral("end_degrees"), QStringLiteral("arc"), + geometry.arc.endDegrees, fieldError) + && readNumber(arc, QStringLiteral("inner_gap_pixels"), QStringLiteral("arc"), + geometry.arc.innerGapPixels, fieldError) + && readNumber(arc, QStringLiteral("line_width_pixels"), QStringLiteral("arc"), + geometry.arc.lineWidthPixels, fieldError) + && readObject(root, QStringLiteral("tick_style"), QString(), tickStyle, fieldError) + && readNumber(tickStyle, QStringLiteral("start_offset_pixels"), + QStringLiteral("tick_style"), geometry.tickStyle.startOffsetPixels, + fieldError) + && readNumber(tickStyle, QStringLiteral("end_offset_pixels"), + QStringLiteral("tick_style"), geometry.tickStyle.endOffsetPixels, + fieldError) + && readNumber(tickStyle, QStringLiteral("label_offset_pixels"), + QStringLiteral("tick_style"), geometry.tickStyle.labelOffsetPixels, + fieldError) + && readNumber(tickStyle, QStringLiteral("line_width_pixels"), + QStringLiteral("tick_style"), geometry.tickStyle.lineWidthPixels, + fieldError) + && readInt(tickStyle, QStringLiteral("font_minimum_pixels"), + QStringLiteral("tick_style"), geometry.tickStyle.fontMinimumPixels, + fieldError) + && readNumber(tickStyle, QStringLiteral("font_height_factor"), + QStringLiteral("tick_style"), geometry.tickStyle.fontHeightFactor, + fieldError) + && readBool(tickStyle, QStringLiteral("bold"), QStringLiteral("tick_style"), + geometry.tickStyle.bold, fieldError) + && readObject(root, QStringLiteral("rx_scale"), QString(), rxScale, fieldError) + && readNumber(rxScale, QStringLiteral("minimum_dbm"), QStringLiteral("rx_scale"), + geometry.rxScale.minimumDbm, fieldError) + && readNumber(rxScale, QStringLiteral("s9_dbm"), QStringLiteral("rx_scale"), + geometry.rxScale.s9Dbm, fieldError) + && readNumber(rxScale, QStringLiteral("maximum_dbm"), QStringLiteral("rx_scale"), + geometry.rxScale.maximumDbm, fieldError) + && readNumber(rxScale, QStringLiteral("db_per_s_unit"), QStringLiteral("rx_scale"), + geometry.rxScale.dbPerSUnit, fieldError) + && readNumber(rxScale, QStringLiteral("s9_fraction"), QStringLiteral("rx_scale"), + geometry.rxScale.s9Fraction, fieldError) + && readTicks(rxScale, QStringLiteral("ticks"), QStringLiteral("rx_scale"), + geometry.rxScale.ticks, fieldError) + && readStaticScale(root, QStringLiteral("swr_scale"), geometry.swrScale, fieldError) + && readStaticScale(root, QStringLiteral("level_scale"), geometry.levelScale, fieldError) + && readStaticScale(root, QStringLiteral("compression_scale"), + geometry.compressionScale, fieldError) + && readObject(root, QStringLiteral("needle"), QString(), needle, fieldError) + && readNumber(needle, QStringLiteral("pivot_y_below_widget_pixels"), + QStringLiteral("needle"), geometry.needle.pivotYBelowWidgetPixels, + fieldError) + && readNumber(needle, QStringLiteral("tip_extension_pixels"), + QStringLiteral("needle"), geometry.needle.tipExtensionPixels, + fieldError) + && readNumber(needle, QStringLiteral("line_width_pixels"), QStringLiteral("needle"), + geometry.needle.lineWidthPixels, fieldError) + && readNumber(needle, QStringLiteral("shadow_width_pixels"), + QStringLiteral("needle"), geometry.needle.shadowWidthPixels, fieldError) + && readPoint(needle, QStringLiteral("shadow_offset"), QStringLiteral("needle"), + geometry.needle.shadowOffset, fieldError) + && readObject(root, QStringLiteral("pivot"), QString(), pivot, fieldError) + && readNumber(pivot, QStringLiteral("minimum_radius_pixels"), QStringLiteral("pivot"), + geometry.pivot.minimumRadiusPixels, fieldError) + && readNumber(pivot, QStringLiteral("radius_width_factor"), QStringLiteral("pivot"), + geometry.pivot.radiusWidthFactor, fieldError) + && readNumber(pivot, QStringLiteral("glow_radius_factor"), QStringLiteral("pivot"), + geometry.pivot.glowRadiusFactor, fieldError) + && readNumber(pivot, QStringLiteral("glow_middle_factor"), QStringLiteral("pivot"), + geometry.pivot.glowMiddleFactor, fieldError) + && readInt(pivot, QStringLiteral("glow_center_alpha"), QStringLiteral("pivot"), + geometry.pivot.glowCenterAlpha, fieldError) + && readInt(pivot, QStringLiteral("glow_middle_alpha"), QStringLiteral("pivot"), + geometry.pivot.glowMiddleAlpha, fieldError) + && readNumber(pivot, QStringLiteral("rim_width_pixels"), QStringLiteral("pivot"), + geometry.pivot.rimWidthPixels, fieldError) + && readObject(root, QStringLiteral("peak_marker"), QString(), peakMarker, fieldError) + && readNumber(peakMarker, QStringLiteral("radius_inset_pixels"), + QStringLiteral("peak_marker"), geometry.peakMarker.radiusInsetPixels, + fieldError) + && readNumber(peakMarker, QStringLiteral("length_pixels"), + QStringLiteral("peak_marker"), geometry.peakMarker.lengthPixels, + fieldError) + && readNumber(peakMarker, QStringLiteral("half_width_pixels"), + QStringLiteral("peak_marker"), geometry.peakMarker.halfWidthPixels, + fieldError) + && readNumber(peakMarker, QStringLiteral("minimum_lead_db"), + QStringLiteral("peak_marker"), geometry.peakMarker.minimumLeadDb, + fieldError) + && readObject(root, QStringLiteral("peak_hold"), QString(), peakHold, fieldError) + && readNumber(peakHold, QStringLiteral("inner_radius_offset_pixels"), + QStringLiteral("peak_hold"), geometry.peakHold.innerRadiusOffsetPixels, + fieldError) + && readNumber(peakHold, QStringLiteral("outer_radius_offset_pixels"), + QStringLiteral("peak_hold"), geometry.peakHold.outerRadiusOffsetPixels, + fieldError) + && readNumber(peakHold, QStringLiteral("line_width_pixels"), + QStringLiteral("peak_hold"), geometry.peakHold.lineWidthPixels, + fieldError) + && readNumber(peakHold, QStringLiteral("visible_above_minimum_db"), + QStringLiteral("peak_hold"), geometry.peakHold.visibleAboveMinimumDb, + fieldError) + && readObject(root, QStringLiteral("readout"), QString(), readout, fieldError) + && readInt(readout, QStringLiteral("source_font_minimum_pixels"), + QStringLiteral("readout"), geometry.readout.sourceFontMinimumPixels, + fieldError) + && readNumber(readout, QStringLiteral("source_font_height_divisor"), + QStringLiteral("readout"), geometry.readout.sourceFontHeightDivisor, + fieldError) + && readInt(readout, QStringLiteral("value_font_minimum_pixels"), + QStringLiteral("readout"), geometry.readout.valueFontMinimumPixels, + fieldError) + && readNumber(readout, QStringLiteral("value_font_height_divisor"), + QStringLiteral("readout"), geometry.readout.valueFontHeightDivisor, + fieldError) + && readInt(readout, QStringLiteral("top_extra_pixels"), QStringLiteral("readout"), + geometry.readout.topExtraPixels, fieldError) + && readInt(readout, QStringLiteral("side_margin_pixels"), QStringLiteral("readout"), + geometry.readout.sideMarginPixels, fieldError); + if (!fixedFieldsValid) { + return rejectedGeometry(error, fieldError); + } + + const QJsonValue policiesValue = root.value(QStringLiteral("power_tick_policies")); + if (!policiesValue.isArray()) { + return rejectedGeometry(error, + QStringLiteral("s-meter-v1.json: power_tick_policies must be an array")); + } + const QJsonArray policies = policiesValue.toArray(); + geometry.powerTickPolicies.clear(); + geometry.powerTickPolicies.reserve(policies.size()); + for (qsizetype index = 0; index < policies.size(); ++index) { + const QString path = QStringLiteral("power_tick_policies[%1]").arg(index); + if (!policies.at(index).isObject()) { + return rejectedGeometry( + error, QStringLiteral("s-meter-v1.json: %1 must be an object").arg(path)); + } + const QJsonObject policyObject = policies.at(index).toObject(); + PowerTickPolicy policy; + if (!readNumber(policyObject, QStringLiteral("minimum_scale_watts"), path, + policy.minimumScaleWatts, fieldError) + || !readInt(policyObject, QStringLiteral("tick_step_watts"), path, + policy.tickStepWatts, fieldError) + || !readInt(policyObject, QStringLiteral("label_step_watts"), path, + policy.labelStepWatts, fieldError)) { + return rejectedGeometry(error, fieldError); + } + geometry.powerTickPolicies.push_back(policy); + } + + QString validationError; + if (!geometry.isValid(&validationError)) { + return rejectedGeometry(error, validationError); + } + return geometry; +} + +SMeterGeometry SMeterGeometry::fallback() +{ + SMeterGeometry geometry; + geometry.formatVersion = 1; + geometry.designVersion = 5; + geometry.rxScale.ticks = {{-121.0, QStringLiteral("1")}, + {-109.0, QStringLiteral("3")}, + {-97.0, QStringLiteral("5")}, + {-85.0, QStringLiteral("7")}, + {-73.0, QStringLiteral("9")}, + {-53.0, QStringLiteral("+20")}, + {-33.0, QStringLiteral("+40")}}; + geometry.swrScale = + {1.0, + 3.0, + 2.5, + true, + {{1.0, QStringLiteral("1")}, + {1.5, QStringLiteral("1.5")}, + {2.0, QStringLiteral("2")}, + {2.5, QStringLiteral("2.5")}, + {3.0, QStringLiteral("3")}}}; + geometry.levelScale = + {-40.0, + 5.0, + 0.0, + true, + {{-40.0, QStringLiteral("-40")}, + {-30.0, QStringLiteral("-30")}, + {-20.0, QStringLiteral("-20")}, + {-10.0, QStringLiteral("-10")}, + {0.0, QStringLiteral("0")}}}; + geometry.compressionScale = + {0.0, + 25.0, + 0.0, + false, + {{0.0, QStringLiteral("0")}, + {5.0, QStringLiteral("-5")}, + {10.0, QStringLiteral("-10")}, + {15.0, QStringLiteral("-15")}, + {20.0, QStringLiteral("-20")}, + {25.0, QStringLiteral("-25")}}}; + geometry.powerTickPolicies = {{2000.0, 100, 500}, {600.0, 50, 100}, {0.0, 10, 40}}; + return geometry; +} + +bool SMeterGeometry::isValid(QString* error) const +{ + if (error) { + error->clear(); + } + const auto fail = [error](const QString& message) { + if (error) { + *error = message; + } + return false; + }; + + if (formatVersion != 1 || designVersion <= 0) { + return fail(QStringLiteral("unsupported standard S-meter geometry version")); + } + const double preferredAspectRatio = + static_cast(sizing.preferred.width()) + / static_cast(std::max(sizing.preferred.height(), 1)); + if (sizing.preferred.width() <= 0 || sizing.preferred.height() <= 0 + || sizing.minimum.width() <= 0 || sizing.minimum.height() <= 0 + || sizing.preferred.width() < sizing.minimum.width() + || sizing.preferred.height() < sizing.minimum.height() + || !positive(sizing.minimumAspectRatio) + || !positive(sizing.maximumAspectRatio) + || sizing.minimumAspectRatio >= sizing.maximumAspectRatio + || preferredAspectRatio < sizing.minimumAspectRatio + || preferredAspectRatio > sizing.maximumAspectRatio) { + return fail(QStringLiteral("standard S-meter sizing is invalid")); + } + if (!(arc.centerXWidthFactor > 0.0 && arc.centerXWidthFactor < 1.0) + || !positive(arc.radiusWidthFactor) || !positive(arc.centerYHeightFactor) + || !(arc.startDegrees > 0.0 && arc.startDegrees < arc.endDegrees + && arc.endDegrees < 180.0) + || !positive(arc.innerGapPixels) || !positive(arc.lineWidthPixels)) { + return fail(QStringLiteral("standard S-meter arc is invalid")); + } + if (!nonNegative(tickStyle.startOffsetPixels) || !positive(tickStyle.endOffsetPixels) + || tickStyle.endOffsetPixels <= tickStyle.startOffsetPixels + || !positive(tickStyle.labelOffsetPixels) + || tickStyle.labelOffsetPixels <= tickStyle.endOffsetPixels + || !positive(tickStyle.lineWidthPixels) || tickStyle.fontMinimumPixels <= 0 + || !positive(tickStyle.fontHeightFactor)) { + return fail(QStringLiteral("standard S-meter tick style is invalid")); + } + if (!finite(rxScale.minimumDbm) || !finite(rxScale.s9Dbm) + || !finite(rxScale.maximumDbm) || rxScale.minimumDbm >= rxScale.s9Dbm + || rxScale.s9Dbm >= rxScale.maximumDbm || !positive(rxScale.dbPerSUnit) + || !(rxScale.s9Fraction > 0.0 && rxScale.s9Fraction < 1.0) + || !nearlyEqual(rxScale.s9Dbm - rxScale.minimumDbm, 9.0 * rxScale.dbPerSUnit) + || !nearlyEqual(rxScale.maximumDbm - rxScale.s9Dbm, 60.0) + || !validTicks(rxScale.ticks, rxScale.minimumDbm, rxScale.maximumDbm)) { + return fail(QStringLiteral("standard S-meter RX scale is invalid")); + } + const auto validStaticScale = [](const StaticScale& scale) { + return finite(scale.minimum) && finite(scale.maximum) && scale.minimum < scale.maximum + && (!scale.hasWarning + || (finite(scale.warningStart) && scale.warningStart >= scale.minimum + && scale.warningStart <= scale.maximum)) + && validTicks(scale.ticks, scale.minimum, scale.maximum); + }; + if (!validStaticScale(swrScale) || !validStaticScale(levelScale) + || !validStaticScale(compressionScale)) { + return fail(QStringLiteral("standard S-meter TX scale is invalid")); + } + if (powerTickPolicies.isEmpty()) { + return fail(QStringLiteral("standard S-meter power tick policies are empty")); + } + double previousMinimum = std::numeric_limits::infinity(); + for (const PowerTickPolicy& policy : powerTickPolicies) { + if (!nonNegative(policy.minimumScaleWatts) || policy.minimumScaleWatts >= previousMinimum + || policy.tickStepWatts <= 0 || policy.labelStepWatts <= 0 + || policy.labelStepWatts % policy.tickStepWatts != 0) { + return fail(QStringLiteral("standard S-meter power tick policy is invalid")); + } + previousMinimum = policy.minimumScaleWatts; + } + if (powerTickPolicies.constLast().minimumScaleWatts != 0.0) { + return fail(QStringLiteral("standard S-meter power tick policies lack a default")); + } + if (!nonNegative(needle.pivotYBelowWidgetPixels) + || !nonNegative(needle.tipExtensionPixels) || !positive(needle.lineWidthPixels) + || !positive(needle.shadowWidthPixels) || !finite(needle.shadowOffset.x()) + || !finite(needle.shadowOffset.y()) || !positive(pivot.minimumRadiusPixels) + || !positive(pivot.radiusWidthFactor) || !(pivot.glowRadiusFactor > 1.0) + || !(pivot.glowMiddleFactor > 0.0 && pivot.glowMiddleFactor < 1.0) + || pivot.glowCenterAlpha < 0 || pivot.glowCenterAlpha > 255 + || pivot.glowMiddleAlpha < 0 || pivot.glowMiddleAlpha > 255 + || !positive(pivot.rimWidthPixels)) { + return fail(QStringLiteral("standard S-meter needle or pivot is invalid")); + } + if (!nonNegative(peakMarker.radiusInsetPixels) || !positive(peakMarker.lengthPixels) + || !positive(peakMarker.halfWidthPixels) || !nonNegative(peakMarker.minimumLeadDb) + || !finite(peakHold.innerRadiusOffsetPixels) + || !finite(peakHold.outerRadiusOffsetPixels) + || peakHold.innerRadiusOffsetPixels >= peakHold.outerRadiusOffsetPixels + || !positive(peakHold.lineWidthPixels) || !nonNegative(peakHold.visibleAboveMinimumDb)) { + return fail(QStringLiteral("standard S-meter peak geometry is invalid")); + } + if (readout.sourceFontMinimumPixels <= 0 || !positive(readout.sourceFontHeightDivisor) + || readout.valueFontMinimumPixels <= 0 || !positive(readout.valueFontHeightDivisor) + || readout.topExtraPixels < 0 || readout.sideMarginPixels < 0) { + return fail(QStringLiteral("standard S-meter readout geometry is invalid")); + } + + const QVector validationSizes = { + QSizeF(sizing.minimum), + QSizeF(sizing.preferred), + QSizeF(sizing.preferred.width() * 2.0, sizing.preferred.height()), + QSizeF(sizing.preferred.width(), sizing.preferred.height() * 1.5), + QSizeF(sizing.minimum.width(), sizing.minimum.height() * 20.0), + QSizeF(sizing.minimum.width() * 10.0, sizing.minimum.height())}; + for (const QSizeF& size : validationSizes) { + const Layout layout = layoutFor(size); + const double markerRadius = layout.radius - peakMarker.radiusInsetPixels; + const double peakHoldInnerRadius = layout.radius + peakHold.innerRadiusOffsetPixels; + if (!positive(layout.radius) || !positive(layout.innerRadius) + || !positive(markerRadius) || !positive(peakHoldInnerRadius) + || !finite(layout.centerX) || !finite(layout.centerY) + || !finite(layout.needlePivotY) || !positive(layout.pivotRadius) + || !layout.viewport.isValid() + || layout.viewport.left() < 0.0 || layout.viewport.top() < 0.0 + || layout.viewport.right() > size.width() + || layout.viewport.bottom() > size.height() + || layout.centerX <= layout.viewport.left() + || layout.centerX >= layout.viewport.right() + || layout.pivotRadius >= layout.viewport.width() * 0.5 + || layout.pivotRadius >= layout.viewport.height()) { + return fail(QStringLiteral("standard S-meter derived layout is invalid")); + } + + const QPointF center(layout.centerX, layout.centerY); + const QPointF pivotPoint(layout.centerX, layout.needlePivotY); + const double pivotDistance = std::hypot( + pivotPoint.x() - center.x(), pivotPoint.y() - center.y()); + const double smallestMovementRadius = + std::min({layout.innerRadius, markerRadius, peakHoldInnerRadius}); + if (!(pivotDistance < smallestMovementRadius)) { + return fail(QStringLiteral("standard S-meter pivot must remain inside movement arcs")); + } + + for (const double fraction : {0.0, 0.5, 1.0}) { + const MovementRay ray = movementRayFor(size, fraction); + const double directionLength = std::hypot(ray.direction.x(), ray.direction.y()); + const std::optional innerPoint = + movementRayCircleIntersection(size, fraction, layout.innerRadius); + if (!nearlyEqual(directionLength, 1.0, 1.0e-8) || !innerPoint + || ray.scalePoint.x() < layout.viewport.left() + || ray.scalePoint.x() > layout.viewport.right() + || ray.scalePoint.y() < layout.viewport.top() + || ray.scalePoint.y() > layout.viewport.bottom()) { + return fail(QStringLiteral("standard S-meter movement ray is invalid")); + } + } + } + return true; +} + +SMeterGeometry::Layout SMeterGeometry::layoutFor(const QSizeF& size) const +{ + Layout layout; + const double width = std::max(size.width(), 1.0); + const double height = std::max(size.height(), 1.0); + const double aspectRatio = width / height; + layout.viewport = QRectF(0.0, 0.0, width, height); + if (aspectRatio < sizing.minimumAspectRatio) { + const double viewportHeight = width / sizing.minimumAspectRatio; + layout.viewport.setTop((height - viewportHeight) * 0.5); + layout.viewport.setHeight(viewportHeight); + } else if (aspectRatio > sizing.maximumAspectRatio) { + const double viewportWidth = height * sizing.maximumAspectRatio; + layout.viewport.setLeft((width - viewportWidth) * 0.5); + layout.viewport.setWidth(viewportWidth); + } + + const double faceWidth = layout.viewport.width(); + const double faceHeight = layout.viewport.height(); + layout.centerX = layout.viewport.left() + faceWidth * arc.centerXWidthFactor; + layout.radius = faceWidth * arc.radiusWidthFactor; + layout.centerY = layout.viewport.top() + + layout.radius + faceHeight * arc.centerYHeightFactor; + layout.needlePivotY = layout.viewport.bottom() + needle.pivotYBelowWidgetPixels; + layout.innerRadius = layout.radius - arc.innerGapPixels; + const double preferredAspect = static_cast(sizing.preferred.width()) + / static_cast(sizing.preferred.height()); + const double pivotScaleWidth = std::min(faceWidth, faceHeight * preferredAspect); + layout.pivotRadius = std::max( + pivot.minimumRadiusPixels, pivotScaleWidth * pivot.radiusWidthFactor); + layout.tickFontPixels = std::max( + tickStyle.fontMinimumPixels, + static_cast(std::floor(faceHeight * tickStyle.fontHeightFactor))); + layout.sourceFontPixels = + std::max(readout.sourceFontMinimumPixels, + static_cast(std::floor(faceHeight / readout.sourceFontHeightDivisor))); + layout.valueFontPixels = + std::max(readout.valueFontMinimumPixels, + static_cast(std::floor(faceHeight / readout.valueFontHeightDivisor))); + return layout; +} + +double SMeterGeometry::fractionToRadians(double fraction) const +{ + const double start = qDegreesToRadians(arc.startDegrees); + const double end = qDegreesToRadians(arc.endDegrees); + return end - std::clamp(fraction, 0.0, 1.0) * (end - start); +} + +double SMeterGeometry::rxFraction(double dbm) const +{ + const double clamped = std::clamp(dbm, rxScale.minimumDbm, rxScale.maximumDbm); + if (clamped <= rxScale.s9Dbm) { + return rxScale.s9Fraction * (clamped - rxScale.minimumDbm) + / (rxScale.s9Dbm - rxScale.minimumDbm); + } + return rxScale.s9Fraction + + (1.0 - rxScale.s9Fraction) * (clamped - rxScale.s9Dbm) + / (rxScale.maximumDbm - rxScale.s9Dbm); +} + +double SMeterGeometry::scaleFraction(const StaticScale& scale, double value) const +{ + return std::clamp((value - scale.minimum) / (scale.maximum - scale.minimum), 0.0, 1.0); +} + +SMeterGeometry::MovementRay SMeterGeometry::movementRayFor( + const QSizeF& size, double fraction) const +{ + const Layout layout = layoutFor(size); + const double angle = fractionToRadians(fraction); + const QPointF pivot(layout.centerX, layout.needlePivotY); + const QPointF scalePoint(layout.centerX + layout.radius * std::cos(angle), + layout.centerY - layout.radius * std::sin(angle)); + const QPointF delta = scalePoint - pivot; + const double length = std::hypot(delta.x(), delta.y()); + const QPointF direction = length > 0.0 ? delta / length : QPointF(); + return {pivot, scalePoint, direction}; +} + +std::optional SMeterGeometry::movementRayCircleIntersection( + const QSizeF& size, double fraction, double radius) const +{ + if (!positive(radius)) { + return std::nullopt; + } + + const Layout layout = layoutFor(size); + const MovementRay ray = movementRayFor(size, fraction); + const double directionLength = std::hypot(ray.direction.x(), ray.direction.y()); + if (directionLength <= 0.0) { + return std::nullopt; + } + + const QPointF center(layout.centerX, layout.centerY); + const QPointF relativePivot = ray.pivot - center; + const double projection = QPointF::dotProduct(relativePivot, ray.direction); + const double constant = QPointF::dotProduct(relativePivot, relativePivot) - radius * radius; + const double discriminant = projection * projection - constant; + if (discriminant < 0.0) { + return std::nullopt; + } + + const double distance = -projection + std::sqrt(discriminant); + if (distance < 0.0 || !finite(distance)) { + return std::nullopt; + } + return ray.pivot + distance * ray.direction; +} + +QPointF SMeterGeometry::needleTip(const QSizeF& size, double fraction) const +{ + const MovementRay ray = movementRayFor(size, fraction); + return ray.scalePoint + needle.tipExtensionPixels * ray.direction; +} + +const SMeterGeometry::PowerTickPolicy& SMeterGeometry::powerTickPolicy(double maximumWatts) const +{ + for (const PowerTickPolicy& policy : powerTickPolicies) { + if (maximumWatts >= policy.minimumScaleWatts) { + return policy; + } + } + return powerTickPolicies.constLast(); +} + +} // namespace AetherSDR diff --git a/src/gui/SMeterGeometry.h b/src/gui/SMeterGeometry.h new file mode 100644 index 000000000..bea19ac68 --- /dev/null +++ b/src/gui/SMeterGeometry.h @@ -0,0 +1,177 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include + +class QIODevice; + +namespace AetherSDR { + +// Versioned, editable construction for the standard S-meter face. +// +// The shipping values live in resources/meterfaces/s-meter-v1.json. Unlike +// the fixed-aspect cross-needle face, this meter intentionally keeps its +// existing responsive width/height formulas so applet and detached-window +// resizing remain pixel-for-pixel compatible with the original painter. +class SMeterGeometry { +public: + struct Tick { + double value{0.0}; + QString label; + }; + + struct Sizing { + QSize preferred{280, 140}; + QSize minimum{200, 100}; + // Outside this supported width/height range, the meter face is + // centered in a bounded viewport instead of stretching its movement + // geometry until the pivot leaves the printed arcs. + double minimumAspectRatio{1.75}; + double maximumAspectRatio{4.0}; + }; + + struct Arc { + double centerXWidthFactor{0.5}; + double radiusWidthFactor{0.85}; + // arcCenterY = radius + height * centerYHeightFactor + double centerYHeightFactor{0.35}; + double startDegrees{55.0}; + double endDegrees{125.0}; + double innerGapPixels{6.0}; + double lineWidthPixels{3.0}; + }; + + struct TickStyle { + double startOffsetPixels{2.0}; + double endOffsetPixels{14.0}; + double labelOffsetPixels{26.0}; + double lineWidthPixels{1.5}; + int fontMinimumPixels{10}; + double fontHeightFactor{0.1}; + bool bold{true}; + }; + + struct RxScale { + double minimumDbm{-127.0}; + double s9Dbm{-73.0}; + double maximumDbm{-13.0}; + double dbPerSUnit{6.0}; + double s9Fraction{0.6}; + QVector ticks; + }; + + struct StaticScale { + double minimum{0.0}; + double maximum{1.0}; + double warningStart{0.0}; + bool hasWarning{false}; + QVector ticks; + }; + + struct PowerTickPolicy { + double minimumScaleWatts{0.0}; + int tickStepWatts{10}; + int labelStepWatts{40}; + }; + + struct Needle { + double pivotYBelowWidgetPixels{6.0}; + double tipExtensionPixels{14.0}; + double lineWidthPixels{2.0}; + double shadowWidthPixels{3.0}; + QPointF shadowOffset{1.0, 1.0}; + }; + + struct Pivot { + double minimumRadiusPixels{13.5}; + // The width-derived radius is capped by the preferred face aspect + // ratio so a wide, short floating window cannot inflate the mask. + double radiusWidthFactor{0.0975}; + double glowRadiusFactor{3.4}; + double glowMiddleFactor{0.45}; + int glowCenterAlpha{80}; + int glowMiddleAlpha{28}; + double rimWidthPixels{1.0}; + }; + + struct PeakMarker { + double radiusInsetPixels{2.0}; + double lengthPixels{6.0}; + double halfWidthPixels{3.0}; + double minimumLeadDb{1.0}; + }; + + struct PeakHold { + double innerRadiusOffsetPixels{-4.0}; + double outerRadiusOffsetPixels{10.0}; + double lineWidthPixels{2.0}; + double visibleAboveMinimumDb{1.0}; + }; + + struct Readout { + int sourceFontMinimumPixels{9}; + double sourceFontHeightDivisor{14.0}; + int valueFontMinimumPixels{13}; + double valueFontHeightDivisor{8.0}; + int topExtraPixels{4}; + int sideMarginPixels{6}; + }; + + struct Layout { + QRectF viewport; + double centerX{0.0}; + double radius{0.0}; + double centerY{0.0}; + double needlePivotY{0.0}; + double innerRadius{0.0}; + double pivotRadius{0.0}; + int tickFontPixels{0}; + int sourceFontPixels{0}; + int valueFontPixels{0}; + }; + + struct MovementRay { + QPointF pivot; + QPointF scalePoint; + QPointF direction; + }; + + static SMeterGeometry loadResource(QString* error = nullptr); + static SMeterGeometry load(QIODevice& device, QString* error = nullptr); + static SMeterGeometry fallback(); + + bool isValid(QString* error = nullptr) const; + Layout layoutFor(const QSizeF& size) const; + double fractionToRadians(double fraction) const; + double rxFraction(double dbm) const; + double scaleFraction(const StaticScale& scale, double value) const; + MovementRay movementRayFor(const QSizeF& size, double fraction) const; + std::optional movementRayCircleIntersection( + const QSizeF& size, double fraction, double radius) const; + QPointF needleTip(const QSizeF& size, double fraction) const; + const PowerTickPolicy& powerTickPolicy(double maximumWatts) const; + + int formatVersion{0}; + int designVersion{0}; + Sizing sizing; + Arc arc; + TickStyle tickStyle; + RxScale rxScale; + StaticScale swrScale; + StaticScale levelScale; + StaticScale compressionScale; + QVector powerTickPolicies; + Needle needle; + Pivot pivot; + PeakMarker peakMarker; + PeakHold peakHold; + Readout readout; +}; + +} // namespace AetherSDR diff --git a/src/gui/SMeterWidget.cpp b/src/gui/SMeterWidget.cpp index 201c2d691..5393d156d 100644 --- a/src/gui/SMeterWidget.cpp +++ b/src/gui/SMeterWidget.cpp @@ -1,35 +1,102 @@ #include "SMeterWidget.h" +#include "SMeterWidgetAccessible.h" +#include "core/LogManager.h" #include "core/ThemeManager.h" #include #include #include +#include #include #include #include #include -#include +#include +#include namespace AetherSDR { +SMeterWidgetAccessible::SMeterWidgetAccessible(QWidget* widget) + : QAccessibleWidget(widget, QAccessible::Indicator) +{ +} + +QString SMeterWidgetAccessible::text(QAccessible::Text textType) const +{ + const SMeterWidget* meter = qobject_cast(widget()); + if (meter && textType == QAccessible::Value) { + return meter->accessibleValueText(); + } + return QAccessibleWidget::text(textType); +} + +QAccessibleInterface* sMeterAccessibleFactory(const QString& key, QObject* object) +{ + if (key == QLatin1String("AetherSDR::SMeterWidget")) { + return new SMeterWidgetAccessible(qobject_cast(object)); + } + return nullptr; +} + // --- Construction ------------------------------------------------------------ SMeterWidget::SMeterWidget(QWidget* parent) - : QWidget(parent) + : QWidget(parent), m_geometry(SMeterGeometry::fallback()) { + static bool s_accessibilityFactoryInstalled = false; + if (!s_accessibilityFactoryInstalled) { + s_accessibilityFactoryInstalled = true; + QAccessible::installFactory(sMeterAccessibleFactory); + } + + QString geometryError; + const SMeterGeometry loadedGeometry = SMeterGeometry::loadResource(&geometryError); + if (loadedGeometry.isValid()) { + m_geometry = loadedGeometry; + } else { + qCWarning(lcMeters).noquote() + << "SMeterWidget: using fallback geometry:" << geometryError; + } + + QString themeError; + m_faceThemes = AnalogMeterFaceThemeCatalog::loadResource(&themeError); + if (!m_faceThemes.isValid()) { + qCWarning(lcMeters).noquote() + << "SMeterWidget: using fallback face themes:" << themeError; + m_faceThemes = AnalogMeterFaceThemeCatalog::fallback(); + } + + const float minimumDbm = static_cast(m_geometry.rxScale.minimumDbm); + m_levelDbm = minimumDbm; + m_peakDbm = minimumDbm; + m_peakHoldDbm = minimumDbm; + m_peakHoldDecayStartDbm = minimumDbm; + setMinimumSize(minimumSizeHint()); setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Fixed); setFocusPolicy(Qt::TabFocus); + setProperty("faceTheme", faceThemeId()); + + connect(&ThemeManager::instance(), &ThemeManager::themeChanged, this, [this]() { + if (m_faceTheme == AnalogMeterFaceTheme::AetherDefault) { + m_backgroundCacheValid = false; + update(); + } + }); m_needleFraction = dbmToFraction(m_levelDbm); m_targetNeedleFraction = m_needleFraction; - m_peakHoldDecayStartDbm = m_peakHoldDbm; - + m_radioSwrClock.start(); m_needleAnimation.setTimerType(Qt::PreciseTimer); m_needleAnimation.setInterval(kNeedleAnimationIntervalMs); connect(&m_needleAnimation, &QTimer::timeout, this, &SMeterWidget::animateNeedle); + m_accessibilityTimer.setSingleShot(true); + m_accessibilityTimer.setInterval(kAccessibilityAnnouncementIntervalMs); + connect(&m_accessibilityTimer, &QTimer::timeout, + this, &SMeterWidget::publishAccessibleValue); + // Peak hold decay: drops 0.5 dB every 50 ms after a new peak m_peakDecay.setInterval(50); connect(&m_peakDecay, &QTimer::timeout, this, [this]() { @@ -40,6 +107,7 @@ SMeterWidget::SMeterWidget(QWidget* parent) } updateNeedleTarget(); update(); + scheduleAccessibleValue(); }); // Hard reset peak hold every 10 seconds @@ -49,12 +117,61 @@ SMeterWidget::SMeterWidget(QWidget* parent) m_peakDbm = m_levelDbm; updateNeedleTarget(); update(); + scheduleAccessibleValue(); }); } // --- Public interface -------------------------------------------------------- -void SMeterWidget::setLevel(float dbm) +void SMeterWidget::setFloating(bool floating) +{ + setSizePolicy(floating ? QSizePolicy::Expanding : QSizePolicy::Preferred, + floating ? QSizePolicy::Expanding : QSizePolicy::Fixed); + updateGeometry(); +} + +QString SMeterWidget::faceThemeId() const +{ + return analogMeterFaceThemeId(m_faceTheme); +} + +QString SMeterWidget::accessibleValueText() const +{ + if (m_transmitting) { + switch (m_txMode) { + case TxMode::Power: + return tr("Transmit power, %1 watts").arg(m_txPower, 0, 'f', 0); + case TxMode::SWR: + return tr("Transmit SWR, %1").arg(m_txSwr, 0, 'f', 1); + case TxMode::Level: + return tr("Transmit level, %1 dB").arg(m_micLevel, 0, 'f', 0); + case TxMode::Compression: + return tr("Transmit compression, %1 dB").arg(-m_compLevel, 0, 'f', 0); + } + } + + if (usesUnavailableRxMeter()) { + return unavailableRxMeterLabel(); + } + const float displayDbm = + m_rxMode == RxMode::SMeterPeak ? m_peakDbm : m_levelDbm; + return tr("%1, %2 dBm") + .arg(sUnitsTextFor(displayDbm)) + .arg(static_cast(displayDbm)); +} + +void SMeterWidget::setFaceTheme(AnalogMeterFaceTheme theme) +{ + if (m_faceTheme == theme) { + return; + } + m_faceTheme = theme; + m_backgroundCacheValid = false; + setProperty("faceTheme", faceThemeId()); + update(); +} + +void SMeterWidget::setLevel(float dbm) // a11y-check: skip -- settled update is timer-throttled { m_receiveMeterReadingActive = false; m_levelDbm = dbm; @@ -80,23 +197,7 @@ void SMeterWidget::setLevel(float dbm) if (!m_transmitting) { update(); - if (hasFocus() && QAccessible::isActive()) { - // Announce the same value that paintEvent displays — in Peak mode - // the needle and text readout track m_peakDbm, not the raw level. - const float displayDbm = (m_rxMode == RxMode::SMeterPeak) ? m_peakDbm : m_levelDbm; - QString sText; - if (displayDbm <= S0_DBM) - sText = QStringLiteral("S0"); - else if (displayDbm <= S9_DBM) - sText = QStringLiteral("S%1").arg(qBound(0, qRound((displayDbm - S0_DBM) / DB_PER_S), 9)); - else - sText = QStringLiteral("S9+%1").arg(qRound(displayDbm - S9_DBM)); - const QString accessText = sText + QStringLiteral(", ") - + QString::number(static_cast(displayDbm)) - + QStringLiteral(" dBm"); - QAccessibleValueChangeEvent event(this, accessText); - QAccessible::updateAccessibility(&event); - } + scheduleAccessibleValue(); } } @@ -124,26 +225,18 @@ void SMeterWidget::setReceiveMeterReading( m_peakHoldTimerRunning = true; } } else { - m_levelDbm = S0_DBM; - m_peakDbm = S0_DBM; - m_peakHoldDbm = S0_DBM; - m_peakHoldDecayStartDbm = S0_DBM; + const float minimumDbm = static_cast(m_geometry.rxScale.minimumDbm); + m_levelDbm = minimumDbm; + m_peakDbm = minimumDbm; + m_peakHoldDbm = minimumDbm; + m_peakHoldDecayStartDbm = minimumDbm; m_peakHoldTimerRunning = false; } updateNeedleTarget(); if (!m_transmitting) { update(); - if (hasFocus() && QAccessible::isActive()) { - QString accessText = unavailableRxMeterLabel(); - if (hasDisplayDbm) { - accessText = reading.label; - accessText += QStringLiteral(", %1 dBm") - .arg(static_cast(reading.dbm)); - } - QAccessibleValueChangeEvent event(this, accessText); - QAccessible::updateAccessibility(&event); - } + scheduleAccessibleValue(); } } @@ -151,7 +244,26 @@ void SMeterWidget::setTxMeters(float fwdPower, float swr) { m_txPower = fwdPower; m_txSwr = swr; + m_radioSwrFilter.reset(); + setProperty("txSwr", m_txSwr); + setProperty("txSwrSource", QStringLiteral("external")); + setProperty("txSwrHeld", false); + setProperty("txSwrPowerEnvelopeWatts", 0.0); + setProperty("txSwrMinimumForwardWatts", 0.05); + + finishTxMeterUpdate(); +} + +void SMeterWidget::setRadioTxMeters(float fwdPower, float fwdPowerInstant, float swr) +{ + m_txPower = fwdPower; + updateRadioSwr(fwdPowerInstant, swr, m_radioSwrClock.elapsed()); + + finishTxMeterUpdate(); +} +void SMeterWidget::finishTxMeterUpdate() +{ updateNeedleTarget(); // Repaint whenever TX power data arrives — either because moxChanged set @@ -160,6 +272,27 @@ void SMeterWidget::setTxMeters(float fwdPower, float swr) if (m_transmitting || m_txPower > 0.5f) { update(); } + if (m_transmitting) { + scheduleAccessibleValue(); + } +} + +void SMeterWidget::updateRadioSwr( + float forwardPowerInstant, float swr, qint64 timestampMs) +{ + const RadioSwrValidityFilter::Result result = + m_radioSwrFilter.update( + forwardPowerInstant, swr, timestampMs, + static_cast(m_geometry.swrScale.maximum)); + m_txSwr = result.displayedSwr; + + setProperty("txSwr", m_txSwr); + setProperty("txSwrRaw", swr); + setProperty("txSwrForwardWatts", forwardPowerInstant); + setProperty("txSwrPowerEnvelopeWatts", result.forwardEnvelopeWatts); + setProperty("txSwrMinimumForwardWatts", result.minimumForwardWatts); + setProperty("txSwrSource", QStringLiteral("radio")); + setProperty("txSwrHeld", result.held); } void SMeterWidget::setMicMeters(float micLevel, float compLevel, float micPeak, float compPeak) @@ -174,20 +307,28 @@ void SMeterWidget::setMicMeters(float micLevel, float compLevel, float micPeak, if (m_transmitting && (m_txMode == TxMode::Level || m_txMode == TxMode::Compression)) { update(); + scheduleAccessibleValue(); } } void SMeterWidget::setTransmitting(bool tx) { m_transmitting = tx; + setProperty("transmitting", tx); if (!tx) { // Clear TX values immediately on un-key so the RX reading becomes the // animation target as soon as transmit ends. m_txPower = 0.0f; m_txSwr = 1.0f; + m_radioSwrFilter.reset(); + setProperty("txSwr", m_txSwr); + setProperty("txSwrHeld", false); + setProperty("txSwrPowerEnvelopeWatts", 0.0); + setProperty("txSwrMinimumForwardWatts", 0.05); } updateNeedleTarget(); update(); + scheduleAccessibleValue(); } void SMeterWidget::setTxMode(const QString& mode) @@ -196,8 +337,10 @@ void SMeterWidget::setTxMode(const QString& mode) else if (mode == "SWR") m_txMode = TxMode::SWR; else if (mode == "Level") m_txMode = TxMode::Level; else if (mode == "Compression") m_txMode = TxMode::Compression; + setProperty("txMode", mode); updateNeedleTarget(); update(); + scheduleAccessibleValue(); } void SMeterWidget::setRxMode(const QString& mode) @@ -211,6 +354,7 @@ void SMeterWidget::setRxMode(const QString& mode) } updateNeedleTarget(); update(); + scheduleAccessibleValue(); } void SMeterWidget::updateNeedleTarget() @@ -307,6 +451,32 @@ QString SMeterWidget::unavailableRxMeterLabel() const return QStringLiteral("Meter unavailable"); } +void SMeterWidget::scheduleAccessibleValue() +{ + // Meter streams can update tens of times per second. Publish at most one + // current value per interval instead of turning a focused screen reader + // into a sample-by-sample audio stream. The timeout always reads current + // widget state, so a burst collapses to its latest RX/TX value or mode. + if (hasFocus() && QAccessible::isActive() && !m_accessibilityTimer.isActive()) { + m_accessibilityTimer.start(); + } +} + +void SMeterWidget::publishAccessibleValue() +{ + if (!hasFocus() || !QAccessible::isActive()) { + return; + } + + const QString value = accessibleValueText(); + if (value == m_lastAccessibleValue) { + return; + } + m_lastAccessibleValue = value; + QAccessibleValueChangeEvent event(this, value); + QAccessible::updateAccessibility(&event); +} + void SMeterWidget::updatePeakHoldValue() { if (!m_peakHoldEnabled || !m_peakHoldTimerRunning) { @@ -328,29 +498,27 @@ void SMeterWidget::updatePeakHoldValue() QString SMeterWidget::sUnitsText() const { - if (m_levelDbm <= S0_DBM) return "S0"; - if (m_levelDbm <= S9_DBM) { - const int s = qRound((m_levelDbm - S0_DBM) / DB_PER_S); - return QString("S%1").arg(qBound(0, s, 9)); + return sUnitsTextFor(m_levelDbm); +} + +QString SMeterWidget::sUnitsTextFor(float dbm) const +{ + const SMeterGeometry::RxScale& scale = m_geometry.rxScale; + if (dbm <= scale.minimumDbm) { + return QStringLiteral("S0"); + } + if (dbm <= scale.s9Dbm) { + const int s = qRound((dbm - scale.minimumDbm) / scale.dbPerSUnit); + return QStringLiteral("S%1").arg(qBound(0, s, 9)); } - const int over = qRound(m_levelDbm - S9_DBM); - return QString("S9+%1").arg(over); + return QStringLiteral("S9+%1").arg(qRound(dbm - scale.s9Dbm)); } // --- Mapping ----------------------------------------------------------------- float SMeterWidget::dbmToFraction(float dbm) const { - // S0 to S9 occupies the left 60% of the arc - // S9 to S9+60 occupies the right 40% - const float clamped = qBound(S0_DBM, dbm, MAX_DBM); - - if (clamped <= S9_DBM) { - // Linear within S0..S9 -> 0.0..0.6 - return 0.6f * (clamped - S0_DBM) / (S9_DBM - S0_DBM); - } - // Linear within S9..S9+60 -> 0.6..1.0 - return 0.6f + 0.4f * (clamped - S9_DBM) / (MAX_DBM - S9_DBM); + return static_cast(m_geometry.rxFraction(dbm)); } float SMeterWidget::txValueToFraction(float value) const @@ -359,14 +527,11 @@ float SMeterWidget::txValueToFraction(float value) const case TxMode::Power: return qBound(0.0f, value / m_powerScaleMax, 1.0f); case TxMode::SWR: - // 1.0-3.0 - return qBound(0.0f, (value - 1.0f) / 2.0f, 1.0f); + return static_cast(m_geometry.scaleFraction(m_geometry.swrScale, value)); case TxMode::Level: - // -40 to +5 - return qBound(0.0f, (value + 40.0f) / 45.0f, 1.0f); + return static_cast(m_geometry.scaleFraction(m_geometry.levelScale, value)); case TxMode::Compression: - // Compression: 0 = none, 25 = heavy compression - return qBound(0.0f, value / 25.0f, 1.0f); + return static_cast(m_geometry.scaleFraction(m_geometry.compressionScale, value)); } return 0.0f; } @@ -384,6 +549,103 @@ float SMeterWidget::currentTxValue() const // --- Paint ------------------------------------------------------------------- +void SMeterWidget::rebuildBackgroundLayer() +{ + const qreal dpr = devicePixelRatioF(); + const QSize pixelSize(qMax(1, qRound(width() * dpr)), + qMax(1, qRound(height() * dpr))); + m_backgroundLayer = QPixmap(pixelSize); + m_backgroundLayer.setDevicePixelRatio(dpr); + m_backgroundLayer.fill(Qt::transparent); + + QPainter painter(&m_backgroundLayer); + painter.setRenderHint(QPainter::Antialiasing); + if (m_faceTheme == AnalogMeterFaceTheme::AetherDefault) { + painter.fillRect(QRectF(QPointF(0.0, 0.0), QSizeF(size())), + QColor(0x0f, 0x0f, 0x1a)); + } else { + m_faceThemes.drawBackground( + painter, QRectF(QPointF(0.0, 0.0), QSizeF(size())), m_faceTheme); + } + + m_backgroundCacheSize = size(); + m_backgroundCacheDpr = dpr; + m_backgroundCacheTheme = m_faceTheme; + m_backgroundCacheValid = true; +} + +void SMeterWidget::drawPhysicalNeedle( + QPainter& painter, const QPointF& pivot, const QPointF& tip, + const QSizeF& faceSize, + const AnalogMeterFaceThemeCatalog::Palette& palette) const +{ + const QPointF movement = tip - pivot; + const double length = std::hypot(movement.x(), movement.y()); + if (!(length > 0.0)) { + return; + } + + const QPointF direction = movement / length; + QPointF normal(-direction.y(), direction.x()); + if (QPointF::dotProduct(normal, QPointF(-1.0, -1.0)) < 0.0) { + normal = -normal; + } + const double materialScale = std::max( + 0.65, std::min(faceSize.width() / 280.0, faceSize.height() / 140.0)); + const double bodyWidth = m_geometry.needle.lineWidthPixels * materialScale; + const double taperLength = 5.0 * materialScale; + const QPointF taperBase = tip - direction * taperLength; + const QPointF contactOffset = m_geometry.needle.shadowOffset * materialScale; + const QPointF softOffset = QPointF(2.2, 2.8) * materialScale; + + painter.setPen(QPen(palette.needleSoftShadow, bodyWidth * 3.0, + Qt::SolidLine, Qt::RoundCap)); + painter.drawLine(pivot + softOffset, tip + softOffset); + painter.setPen(QPen(palette.needleShadow, bodyWidth * 1.8, + Qt::SolidLine, Qt::RoundCap)); + painter.drawLine(pivot + contactOffset, tip + contactOffset); + + painter.setPen(QPen(palette.needle, bodyWidth, + Qt::SolidLine, Qt::FlatCap)); + painter.drawLine(pivot, taperBase); + QPainterPath taperedTip; + taperedTip.moveTo(tip); + taperedTip.lineTo(taperBase + normal * bodyWidth * 0.5); + taperedTip.lineTo(taperBase - normal * bodyWidth * 0.5); + taperedTip.closeSubpath(); + painter.setPen(Qt::NoPen); + painter.setBrush(palette.needle); + painter.drawPath(taperedTip); + + painter.setBrush(Qt::NoBrush); + painter.setPen(QPen(palette.needleEdge, qMax(0.55, bodyWidth * 0.34), + Qt::SolidLine, Qt::FlatCap)); + painter.drawLine(pivot - normal * bodyWidth * 0.34, + taperBase - normal * bodyWidth * 0.34); + painter.setPen(QPen(palette.needleHighlight, qMax(0.45, bodyWidth * 0.27), + Qt::SolidLine, Qt::FlatCap)); + painter.drawLine(pivot + normal * bodyWidth * 0.27, + taperBase + normal * bodyWidth * 0.27); +} + +void SMeterWidget::drawPhysicalMask( + QPainter& painter, const QRectF& face, + const AnalogMeterFaceThemeCatalog::Palette& palette) const +{ + painter.setPen(Qt::NoPen); + painter.setBrush(palette.maskFill); + painter.drawPath(m_faceThemes.lowerMaskPath(face)); + + const QVector boundary = m_faceThemes.lowerMaskBoundary(face); + const double scale = std::max( + 0.75, std::min(face.width() / 280.0, face.height() / 140.0)); + painter.setBrush(Qt::NoBrush); + painter.setPen(QPen(palette.maskEdge, + m_geometry.pivot.rimWidthPixels * scale, + Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin)); + painter.drawPolyline(QPolygonF(boundary)); +} + void SMeterWidget::paintEvent(QPaintEvent*) { QPainter p(this); @@ -392,26 +654,50 @@ void SMeterWidget::paintEvent(QPaintEvent*) const int w = width(); const int h = height(); - // Background - p.fillRect(rect(), QColor(0x0f, 0x0f, 0x1a)); + const qreal dpr = devicePixelRatioF(); + if (!m_backgroundCacheValid || m_backgroundCacheSize != size() + || !qFuzzyCompare(m_backgroundCacheDpr, dpr) + || m_backgroundCacheTheme != m_faceTheme) { + rebuildBackgroundLayer(); + } + p.drawPixmap(0, 0, m_backgroundLayer); + + const bool physicalTheme = m_faceTheme != AnalogMeterFaceTheme::AetherDefault; + const AnalogMeterFaceThemeCatalog::Palette* physicalPalette = + physicalTheme ? &m_faceThemes.palette(m_faceTheme) : nullptr; + const QColor rxNormal = physicalTheme ? physicalPalette->text + : QColor(0xc8, 0xd8, 0xe8); + const QColor warning = physicalTheme ? physicalPalette->scaleCalibration + : QColor(0xff, 0x44, 0x44); + const QColor txNormal = physicalTheme ? physicalPalette->scaleInner + : QColor(0x00, 0x80, 0xd0); + const QColor tickNormal = physicalTheme ? physicalPalette->majorTick + : QColor(0xc8, 0xd8, 0xe8); // -- Arc geometry --------------------------------------------------------- - // Large radius with center far below widget -> shallow ~70 deg arc segment - const float cx = w * 0.5f; - const float radius = w * 0.85f; - const float cy = h + radius - h * 0.65f; // arc center well below widget - const float needleCy = h + 6.0f; // needle origin just below widget bottom - - // Convert arc degrees to radians - const float arcStartRad = qDegreesToRadians(ARC_START_DEG); - const float arcEndRad = qDegreesToRadians(ARC_END_DEG); - const float arcSpanRad = arcEndRad - arcStartRad; + // Large radius with center far below widget -> shallow ~70 deg arc segment. + const SMeterGeometry::Layout layout = m_geometry.layoutFor(QSizeF(w, h)); + const QRectF face = layout.viewport; + const double faceLeft = face.left(); + const double faceRight = face.right(); + const double faceTop = face.top(); + const double faceBottom = face.bottom(); + const double faceCenterX = face.center().x(); + p.setClipRect(face); + const float cx = static_cast(layout.centerX); + const float radius = static_cast(layout.radius); + const float cy = static_cast(layout.centerY); + const float needleCy = static_cast(layout.needlePivotY); + + // QPainter arc endpoints remain in degrees; moving elements use radians. + const float arcStartDeg = static_cast(m_geometry.arc.startDegrees); + const float arcEndDeg = static_cast(m_geometry.arc.endDegrees); const bool unavailableRxScale = usesUnavailableRxMeter(); const bool calibratedRxScale = !unavailableRxScale; - // fraction 0.0 -> left end (ARC_END_DEG), fraction 1.0 -> right end (ARC_START_DEG) + // fraction 0.0 -> left end, fraction 1.0 -> right end auto fractionToAngle = [&](float frac) -> float { - return arcEndRad - frac * arcSpanRad; // radians + return static_cast(m_geometry.fractionToRadians(frac)); }; // -- Draw colored outer arc (RX scale) ------------------------------------ @@ -419,25 +705,26 @@ void SMeterWidget::paintEvent(QPaintEvent*) const QRectF outerArc(cx - radius, cy - radius, radius * 2, radius * 2); // RX face always uses the existing Flex-style S scale. Uncalibrated // Kiwi fallback readings do not move the needle or show fake dBm. - const float s9Deg = qRadiansToDegrees(fractionToAngle(0.6f)); + const float s9Deg = qRadiansToDegrees( + fractionToAngle(static_cast(m_geometry.rxScale.s9Fraction))); - QPen whitePen(QColor(0xc8, 0xd8, 0xe8), 3); + QPen whitePen(rxNormal, m_geometry.arc.lineWidthPixels); p.setPen(whitePen); p.drawArc(outerArc, static_cast(s9Deg * 16), - static_cast((ARC_END_DEG - s9Deg) * 16)); + static_cast((arcEndDeg - s9Deg) * 16)); - QPen redPen(QColor(0xff, 0x44, 0x44), 3); + QPen redPen(warning, m_geometry.arc.lineWidthPixels); p.setPen(redPen); p.drawArc(outerArc, - static_cast(ARC_START_DEG * 16), - static_cast((s9Deg - ARC_START_DEG) * 16)); + static_cast(arcStartDeg * 16), + static_cast((s9Deg - arcStartDeg) * 16)); } // -- Draw colored inner arc (TX scale) -- 6px gap ------------------------- - const float arcGap = 6.0f; - const QColor blueColor(0x00, 0x80, 0xd0); - const QColor redColor(0xff, 0x44, 0x44); + const float arcGap = static_cast(m_geometry.arc.innerGapPixels); + const QColor blueColor = txNormal; + const QColor redColor = warning; { const float innerR = radius - arcGap; const QRectF innerArc(cx - innerR, cy - innerR, innerR * 2, innerR * 2); @@ -446,63 +733,64 @@ void SMeterWidget::paintEvent(QPaintEvent*) float redFrac = -1.0f; // -1 = no red zone switch (m_txMode) { case TxMode::Power: redFrac = m_powerRedStart / m_powerScaleMax; break; - case TxMode::SWR: redFrac = (2.5f - 1.0f) / 2.0f; break; // 0.75 - case TxMode::Level: redFrac = (0.0f + 40.0f) / 45.0f; break; // ~0.89 + case TxMode::SWR: + redFrac = static_cast(m_geometry.scaleFraction( + m_geometry.swrScale, m_geometry.swrScale.warningStart)); + break; + case TxMode::Level: + redFrac = static_cast(m_geometry.scaleFraction( + m_geometry.levelScale, m_geometry.levelScale.warningStart)); + break; case TxMode::Compression: redFrac = -1.0f; break; // all blue } if (redFrac < 0.0f) { // Entire arc is blue - p.setPen(QPen(blueColor, 3)); + p.setPen(QPen(blueColor, m_geometry.arc.lineWidthPixels)); p.drawArc(innerArc, - static_cast(ARC_START_DEG * 16), - static_cast((ARC_END_DEG - ARC_START_DEG) * 16)); + static_cast(arcStartDeg * 16), + static_cast((arcEndDeg - arcStartDeg) * 16)); } else { const float splitDeg = qRadiansToDegrees(fractionToAngle(redFrac)); - p.setPen(QPen(blueColor, 3)); + p.setPen(QPen(blueColor, m_geometry.arc.lineWidthPixels)); p.drawArc(innerArc, static_cast(splitDeg * 16), - static_cast((ARC_END_DEG - splitDeg) * 16)); - p.setPen(QPen(redColor, 3)); + static_cast((arcEndDeg - splitDeg) * 16)); + p.setPen(QPen(redColor, m_geometry.arc.lineWidthPixels)); p.drawArc(innerArc, - static_cast(ARC_START_DEG * 16), - static_cast((splitDeg - ARC_START_DEG) * 16)); + static_cast(arcStartDeg * 16), + static_cast((splitDeg - arcStartDeg) * 16)); } } // -- Tick drawing helpers ------------------------------------------------- QFont tickFont = font(); - tickFont.setPixelSize(qMax(10, h / 10)); - tickFont.setBold(true); + tickFont.setPixelSize(layout.tickFontPixels); + tickFont.setBold(m_geometry.tickStyle.bold); p.setFont(tickFont); const QFontMetrics tfm(tickFont); - // Direction from needle origin through arc point, normalized - auto needleDir = [&](float angle) -> std::pair { - const float arcX = cx + radius * std::cos(angle); - const float arcY = cy - radius * std::sin(angle); - const float dx = arcX - cx; - const float dy = arcY - needleCy; - const float len = std::sqrt(dx * dx + dy * dy); - return {dx / len, dy / len}; - }; - // Outside tick (RX): extends outward from the arc, label above auto drawOutsideTick = [&](float frac, const QString& label, const QColor& color, bool showLabel) { - const float angle = fractionToAngle(frac); - const float arcX = cx + radius * std::cos(angle); - const float arcY = cy - radius * std::sin(angle); - auto [ux, uy] = needleDir(angle); - - const QPointF inner(arcX + 2 * ux, arcY + 2 * uy); - const QPointF outer(arcX + 14 * ux, arcY + 14 * uy); - - p.setPen(QPen(color, 1.5)); + const SMeterGeometry::MovementRay ray = + m_geometry.movementRayFor(QSizeF(w, h), frac); + const double arcX = ray.scalePoint.x(); + const double arcY = ray.scalePoint.y(); + const double ux = ray.direction.x(); + const double uy = ray.direction.y(); + + const QPointF inner(arcX + m_geometry.tickStyle.startOffsetPixels * ux, + arcY + m_geometry.tickStyle.startOffsetPixels * uy); + const QPointF outer(arcX + m_geometry.tickStyle.endOffsetPixels * ux, + arcY + m_geometry.tickStyle.endOffsetPixels * uy); + + p.setPen(QPen(color, m_geometry.tickStyle.lineWidthPixels)); p.drawLine(inner, outer); if (showLabel) { - const QPointF labelPt(arcX + 26 * ux, arcY + 26 * uy); + const QPointF labelPt(arcX + m_geometry.tickStyle.labelOffsetPixels * ux, + arcY + m_geometry.tickStyle.labelOffsetPixels * uy); const int tw = tfm.horizontalAdvance(label); p.setPen(color); p.drawText(QPointF(labelPt.x() - tw / 2.0, @@ -515,20 +803,29 @@ void SMeterWidget::paintEvent(QPaintEvent*) auto drawInsideTick = [&](float frac, const QString& label, const QColor& tickColor, const QColor& labelColor, bool showLabel) { - const float angle = fractionToAngle(frac); - // Start from the inner colored arc, not the outer arc - const float iArcX = cx + innerArcR * std::cos(angle); - const float iArcY = cy - innerArcR * std::sin(angle); - auto [ux, uy] = needleDir(angle); + const SMeterGeometry::MovementRay ray = + m_geometry.movementRayFor(QSizeF(w, h), frac); + const std::optional innerArcPoint = + m_geometry.movementRayCircleIntersection(QSizeF(w, h), frac, innerArcR); + if (!innerArcPoint) { + return; + } + const double iArcX = innerArcPoint->x(); + const double iArcY = innerArcPoint->y(); + const double ux = ray.direction.x(); + const double uy = ray.direction.y(); - const QPointF outer(iArcX - 2 * ux, iArcY - 2 * uy); - const QPointF inner(iArcX - 14 * ux, iArcY - 14 * uy); + const QPointF outer(iArcX - m_geometry.tickStyle.startOffsetPixels * ux, + iArcY - m_geometry.tickStyle.startOffsetPixels * uy); + const QPointF inner(iArcX - m_geometry.tickStyle.endOffsetPixels * ux, + iArcY - m_geometry.tickStyle.endOffsetPixels * uy); - p.setPen(QPen(tickColor, 1.5)); + p.setPen(QPen(tickColor, m_geometry.tickStyle.lineWidthPixels)); p.drawLine(inner, outer); if (showLabel) { - const QPointF labelPt(iArcX - 26 * ux, iArcY - 26 * uy); + const QPointF labelPt(iArcX - m_geometry.tickStyle.labelOffsetPixels * ux, + iArcY - m_geometry.tickStyle.labelOffsetPixels * uy); const int tw = tfm.horizontalAdvance(label); p.setPen(labelColor); p.drawText(QPointF(labelPt.x() - tw / 2.0, @@ -536,16 +833,13 @@ void SMeterWidget::paintEvent(QPaintEvent*) } }; - const QColor whiteColor(0xc8, 0xd8, 0xe8); + const QColor whiteColor = tickNormal; + const QColor labelColor = physicalTheme ? physicalPalette->text : whiteColor; // -- Outside ticks (RX) --------------------------------------------------- - for (int s = 1; s <= 9; s += 2) { - const float dbm = S0_DBM + s * DB_PER_S; - drawOutsideTick(dbmToFraction(dbm), QString::number(s), whiteColor, true); - } - for (int over : {20, 40}) { - const float dbm = S9_DBM + over; - drawOutsideTick(dbmToFraction(dbm), QString("+%1").arg(over), redColor, true); + for (const SMeterGeometry::Tick& tick : m_geometry.rxScale.ticks) { + const QColor& color = (tick.value > m_geometry.rxScale.s9Dbm) ? redColor : whiteColor; + drawOutsideTick(dbmToFraction(static_cast(tick.value)), tick.label, color, true); } // -- Inside ticks (TX): scale depends on TX mode -------------------------- @@ -554,18 +848,14 @@ void SMeterWidget::paintEvent(QPaintEvent*) // Dynamic scale based on m_powerScaleMax int maxW = static_cast(m_powerScaleMax); int redW = static_cast(m_powerRedStart); - int tickStep, labelStep; - if (maxW >= 2000) { // PGXL: ticks every 100W, labels every 500W - tickStep = 100; labelStep = 500; - } else if (maxW >= 600) { // Aurora: ticks every 50W, labels every 100W - tickStep = 50; labelStep = 100; - } else { // Barefoot: ticks every 10W, labels every 40W - tickStep = 10; labelStep = 40; - } + const SMeterGeometry::PowerTickPolicy& policy = + m_geometry.powerTickPolicy(m_powerScaleMax); + const int tickStep = policy.tickStepWatts; + const int labelStep = policy.labelStepWatts; for (int w = 0; w <= maxW; w += tickStep) { const float frac = static_cast(w) / m_powerScaleMax; const QColor& tc = (w >= redW) ? redColor : blueColor; - const QColor& lc = (w >= redW) ? redColor : whiteColor; + const QColor& lc = (w >= redW) ? redColor : labelColor; bool isLabeled = (w % labelStep == 0) || w == maxW || w == redW; QString label = (w >= 1000) ? QString("%1k").arg(w / 1000.0f, 0, 'f', (w % 1000) ? 1 : 0) : QString::number(w); @@ -574,51 +864,51 @@ void SMeterWidget::paintEvent(QPaintEvent*) break; } case TxMode::SWR: { - // 1.0-3.0, ticks at 1, 1.5, 2, 2.5, 3. Red starting at 2.5. - for (float s : {1.0f, 1.5f, 2.0f, 2.5f, 3.0f}) { - const float frac = (s - 1.0f) / 2.0f; - const bool red = (s >= 2.5f); + for (const SMeterGeometry::Tick& tick : m_geometry.swrScale.ticks) { + const float frac = static_cast( + m_geometry.scaleFraction(m_geometry.swrScale, tick.value)); + const bool red = m_geometry.swrScale.hasWarning + && tick.value >= m_geometry.swrScale.warningStart; const QColor& tc = red ? redColor : blueColor; - const QColor& lc = red ? redColor : whiteColor; - QString label = (s == static_cast(s)) - ? QString::number(static_cast(s)) - : QString::number(s, 'f', 1); - drawInsideTick(frac, label, tc, lc, true); + const QColor& lc = red ? redColor : labelColor; + drawInsideTick(frac, tick.label, tc, lc, true); } break; } case TxMode::Level: { - // -40 to +5, ticks at -40, -30, -20, -10, 0. Red at 0. - for (int db : {-40, -30, -20, -10, 0}) { - const float frac = (db + 40.0f) / 45.0f; - const bool red = (db >= 0); + for (const SMeterGeometry::Tick& tick : m_geometry.levelScale.ticks) { + const float frac = static_cast( + m_geometry.scaleFraction(m_geometry.levelScale, tick.value)); + const bool red = m_geometry.levelScale.hasWarning + && tick.value >= m_geometry.levelScale.warningStart; const QColor& tc = red ? redColor : blueColor; - const QColor& lc = red ? redColor : whiteColor; - drawInsideTick(frac, QString::number(db), tc, lc, true); + const QColor& lc = red ? redColor : labelColor; + drawInsideTick(frac, tick.label, tc, lc, true); } break; } case TxMode::Compression: { - // Visible face is 0 to -25 dB, while the stored value is 0..25. - for (int db : {0, -5, -10, -15, -20, -25}) { - const float frac = -db / 25.0f; - drawInsideTick(frac, QString::number(db), blueColor, whiteColor, true); + for (const SMeterGeometry::Tick& tick : m_geometry.compressionScale.ticks) { + const float frac = static_cast( + m_geometry.scaleFraction(m_geometry.compressionScale, tick.value)); + drawInsideTick(frac, tick.label, blueColor, labelColor, true); } break; } } // Pivot cover radius — shared by the backlight glow and the cover itself. - const float pivotCoverR = qMax(13.5f, w * 0.0975f); + const float pivotCoverR = static_cast(layout.pivotRadius); // -- Pivot backlight glow ------------------------------------------------- // A warm radial glow behind the pivot, as if a lamp sits behind the mask. // Drawn before the needle/cover; the cover masks the bright centre, leaving // a soft halo spilling out from behind the moulding. - { - const float glowR = pivotCoverR * 3.4f; + if (!physicalTheme) { + const float glowR = pivotCoverR * static_cast(m_geometry.pivot.glowRadiusFactor); const float edge = pivotCoverR / glowR; - const float mid = edge + (1.0f - edge) * 0.45f; + const float mid = edge + (1.0f - edge) + * static_cast(m_geometry.pivot.glowMiddleFactor); QColor glowColor = ThemeManager::instance().color(QStringLiteral("color.meter.pivot.glow")); auto glowAlpha = [&glowColor](int a) { @@ -626,14 +916,16 @@ void SMeterWidget::paintEvent(QPaintEvent*) c.setAlpha(a); return c; }; - QRadialGradient glow(QPointF(cx, h), glowR, QPointF(cx, h)); - glow.setColorAt(0.0f, glowAlpha(80)); - glow.setColorAt(edge, glowAlpha(80)); - glow.setColorAt(mid, glowAlpha(28)); + QRadialGradient glow(QPointF(cx, faceBottom), glowR, + QPointF(cx, faceBottom)); + glow.setColorAt(0.0f, glowAlpha(m_geometry.pivot.glowCenterAlpha)); + glow.setColorAt(edge, glowAlpha(m_geometry.pivot.glowCenterAlpha)); + glow.setColorAt(mid, glowAlpha(m_geometry.pivot.glowMiddleAlpha)); glow.setColorAt(1.0f, glowAlpha(0)); p.setPen(Qt::NoPen); p.setBrush(glow); - p.drawChord(QRectF(cx - glowR, h - glowR, glowR * 2.0f, glowR * 2.0f), + p.drawChord(QRectF(cx - glowR, faceBottom - glowR, + glowR * 2.0f, glowR * 2.0f), 0, 180 * 16); } @@ -642,29 +934,30 @@ void SMeterWidget::paintEvent(QPaintEvent*) // arc center, so the pivot is barely out of frame. // When transmitting, needle tracks the selected TX meter instead of RX. { - const float angle = fractionToAngle(m_needleFraction); - - // Needle extends to the end of the outer (RX) ticks: radius + 14 - const float tipR = radius + 14; - const float tipX = cx + tipR * std::cos(angle); - const float tipY = cy - tipR * std::sin(angle); - - // Needle shadow - p.setPen(QPen(QColor(0, 0, 0, 80), 3)); - p.drawLine(QPointF(cx + 1, needleCy + 1), QPointF(tipX + 1, tipY + 1)); - - // Needle - p.setPen(QPen(QColor(0xff, 0xff, 0xff), 2)); - p.drawLine(QPointF(cx, needleCy), QPointF(tipX, tipY)); + const QPointF tip = m_geometry.needleTip(QSizeF(w, h), m_needleFraction); + const QPointF pivot(cx, needleCy); + if (physicalTheme) { + drawPhysicalNeedle(p, pivot, tip, face.size(), *physicalPalette); + } else { + // Preserve the established Aether-default needle exactly. + p.setPen(QPen(QColor(0, 0, 0, 80), m_geometry.needle.shadowWidthPixels)); + p.drawLine(pivot + m_geometry.needle.shadowOffset, + tip + m_geometry.needle.shadowOffset); + p.setPen(QPen(QColor(0xff, 0xff, 0xff), m_geometry.needle.lineWidthPixels)); + p.drawLine(pivot, tip); + } } // -- Needle pivot cover --------------------------------------------------- // A filled half-disc at the bottom-centre hides where the needle pivots, // like the moulded bump on a classic analog VU meter. Drawn after the // needle so it masks the base; the needle appears to emerge from under it. - { + if (physicalTheme) { + drawPhysicalMask(p, face, *physicalPalette); + } else { const float coverR = pivotCoverR; - const QRectF coverRect(cx - coverR, h - coverR, coverR * 2.0f, coverR * 2.0f); + const QRectF coverRect(cx - coverR, faceBottom - coverR, + coverR * 2.0f, coverR * 2.0f); p.setPen(Qt::NoPen); p.setBrush(ThemeManager::instance().color( QStringLiteral("color.meter.pivot.fill"))); // moulding @@ -672,82 +965,102 @@ void SMeterWidget::paintEvent(QPaintEvent*) // Subtle glossy rim along the curved top edge. p.setBrush(Qt::NoBrush); p.setPen(QPen(ThemeManager::instance().color( - QStringLiteral("color.meter.pivot.rim")), 1)); + QStringLiteral("color.meter.pivot.rim")), m_geometry.pivot.rimWidthPixels)); p.drawArc(coverRect, 0, 180 * 16); } // Draw peak marker (small triangle) — only in RX S-Meter Peak mode if (!m_transmitting && calibratedRxScale && m_rxMode == RxMode::SMeterPeak - && m_peakDbm > m_levelDbm + 1.0f) { + && m_peakDbm > m_levelDbm + m_geometry.peakMarker.minimumLeadDb) { const float frac = dbmToFraction(m_peakDbm); - const float angle = fractionToAngle(frac); - const float markerR = radius - 2; - - const float cosA = std::cos(angle); - const float sinA = std::sin(angle); - - const QPointF tip(cx + markerR * cosA, cy - markerR * sinA); - - p.setPen(Qt::NoPen); - p.setBrush(QColor(0xff, 0xaa, 0x00)); - const float perpCos = -sinA; - const float perpSin = cosA; - const float sz = 3.0f; - QPainterPath tri; - tri.moveTo(tip); - tri.lineTo(tip.x() - 6 * cosA + sz * perpCos, - tip.y() + 6 * sinA + sz * perpSin); - tri.lineTo(tip.x() - 6 * cosA - sz * perpCos, - tip.y() + 6 * sinA - sz * perpSin); - tri.closeSubpath(); - p.drawPath(tri); + const float markerR = radius - static_cast(m_geometry.peakMarker.radiusInsetPixels); + const SMeterGeometry::MovementRay ray = + m_geometry.movementRayFor(QSizeF(w, h), frac); + const std::optional markerPoint = + m_geometry.movementRayCircleIntersection(QSizeF(w, h), frac, markerR); + if (markerPoint) { + p.setPen(Qt::NoPen); + p.setBrush(physicalTheme ? physicalPalette->scaleCalibration + : QColor(0xff, 0xaa, 0x00)); + const QPointF perpendicular(-ray.direction.y(), ray.direction.x()); + const float halfWidth = static_cast(m_geometry.peakMarker.halfWidthPixels); + const float markerLength = static_cast(m_geometry.peakMarker.lengthPixels); + QPainterPath tri; + tri.moveTo(*markerPoint); + tri.lineTo(*markerPoint - markerLength * ray.direction + halfWidth * perpendicular); + tri.lineTo(*markerPoint - markerLength * ray.direction - halfWidth * perpendicular); + tri.closeSubpath(); + p.drawPath(tri); + } } // -- Draw peak hold line (configurable overlay, independent of RX mode) --- if (m_peakHoldEnabled && !m_transmitting && calibratedRxScale - && m_peakHoldDbm > S0_DBM + 1.0f) { + && m_peakHoldDbm > m_geometry.rxScale.minimumDbm + + m_geometry.peakHold.visibleAboveMinimumDb) { float frac = dbmToFraction(m_peakHoldDbm); if (m_peakHoldDbm <= m_levelDbm + 0.01f) { frac = m_needleFraction; } else { frac = qMax(frac, m_needleFraction); } - const float angle = fractionToAngle(frac); - - const float cosA = std::cos(angle); - const float sinA = std::sin(angle); - const QPointF inner(cx + (radius - 4) * cosA, - cy - (radius - 4) * sinA); - const QPointF outer(cx + (radius + 10) * cosA, - cy - (radius + 10) * sinA); - - p.setPen(QPen(QColor(0xff, 0x44, 0x44, 0xcc), 2)); - p.drawLine(inner, outer); + const float peakInnerRadius = + radius + static_cast(m_geometry.peakHold.innerRadiusOffsetPixels); + const float peakOuterRadius = + radius + static_cast(m_geometry.peakHold.outerRadiusOffsetPixels); + const std::optional inner = + m_geometry.movementRayCircleIntersection(QSizeF(w, h), frac, peakInnerRadius); + const std::optional outer = + m_geometry.movementRayCircleIntersection(QSizeF(w, h), frac, peakOuterRadius); + if (inner && outer) { + QColor peakColor = physicalTheme ? physicalPalette->scaleCalibration + : QColor(0xff, 0x44, 0x44, 0xcc); + if (physicalTheme) { + peakColor.setAlpha(0xcc); + } + p.setPen(QPen(peakColor, + m_geometry.peakHold.lineWidthPixels)); + p.drawLine(*inner, *outer); + } } - // -- Text readout -- all top-aligned on the same baseline ----------------- + // -- Text readout -- aligned top edges with graph clearance ---------------- QFont srcFont = font(); - srcFont.setPixelSize(qMax(9, h / 14)); + srcFont.setPixelSize(layout.sourceFontPixels); const QFontMetrics sfm(srcFont); - const int topY = sfm.height() + 2; QFont valFont = font(); - valFont.setPixelSize(qMax(13, h / 8)); + valFont.setPixelSize(layout.valueFontPixels); valFont.setBold(true); const QFontMetrics vfm(valFont); + // Keep the larger side values clear of the top edge, then align the + // smaller centered label by its font-box top. Center alignment still left + // the source label too close to the calibrated arc numbers below it. + const int valueBaseline = qRound(faceTop) + std::max(sfm.ascent(), vfm.ascent()) + + m_geometry.readout.topExtraPixels; + const int sourceBaseline = + valueBaseline - vfm.ascent() + sfm.ascent(); + const QColor sourceColor = physicalTheme ? physicalPalette->secondaryText + : QColor(0x80, 0x90, 0xa0); + const QColor accentColor = physicalTheme ? physicalPalette->text + : QColor(0x00, 0xb4, 0xd8); + const QColor valueColor = physicalTheme ? physicalPalette->text + : QColor(0xc8, 0xd8, 0xe8); if (m_transmitting) { // TX mode: show TX source label (center), mode name (left), value (right) static const char* txLabels[] = {"Power", "SWR", "Level", "Compression"}; const QString srcLabel = txLabels[static_cast(m_txMode)]; p.setFont(srcFont); - p.setPen(QColor(0x80, 0x90, 0xa0)); - p.drawText((w - sfm.horizontalAdvance(srcLabel)) / 2, topY, srcLabel); + p.setPen(sourceColor); + p.drawText(qRound(faceCenterX - sfm.horizontalAdvance(srcLabel) / 2.0), + sourceBaseline, srcLabel); p.setFont(valFont); // Left: mode name in cyan - p.setPen(QColor(0x00, 0xb4, 0xd8)); - p.drawText(6, topY, "TX"); + p.setPen(accentColor); + p.drawText(qRound(faceLeft) + m_geometry.readout.sideMarginPixels, + valueBaseline, "TX"); // Right: formatted value QString valText; @@ -757,49 +1070,52 @@ void SMeterWidget::paintEvent(QPaintEvent*) case TxMode::Level: valText = QString("%1 dB").arg(m_micLevel, 0, 'f', 0); break; case TxMode::Compression: valText = QString("%1 dB").arg(-m_compLevel, 0, 'f', 0); break; } - p.setPen(QColor(0xc8, 0xd8, 0xe8)); - p.drawText(w - vfm.horizontalAdvance(valText) - 6, topY, valText); + p.setPen(valueColor); + p.drawText(qRound(faceRight) - vfm.horizontalAdvance(valText) + - m_geometry.readout.sideMarginPixels, + valueBaseline, valText); } else { // RX mode: show source label (center), S-units (left), dBm (right) if (unavailableRxScale) { const QString sourceLabel = unavailableRxMeterLabel(); p.setFont(srcFont); - p.setPen(QColor(0x80, 0x90, 0xa0)); - p.drawText((w - sfm.horizontalAdvance(sourceLabel)) / 2, - topY, sourceLabel); + p.setPen(sourceColor); + p.drawText(qRound(faceCenterX - sfm.horizontalAdvance(sourceLabel) / 2.0), + sourceBaseline, sourceLabel); p.setFont(valFont); - p.setPen(QColor(0x00, 0xb4, 0xd8)); - p.drawText(6, topY, QStringLiteral("---")); + p.setPen(accentColor); + p.drawText(qRound(faceLeft) + m_geometry.readout.sideMarginPixels, + valueBaseline, + QStringLiteral("---")); const QString rightText = QStringLiteral("---"); - p.setPen(QColor(0xc8, 0xd8, 0xe8)); - p.drawText(w - vfm.horizontalAdvance(rightText) - 6, - topY, rightText); + p.setPen(valueColor); + p.drawText(qRound(faceRight) - vfm.horizontalAdvance(rightText) + - m_geometry.readout.sideMarginPixels, + valueBaseline, rightText); return; } p.setFont(srcFont); - p.setPen(QColor(0x80, 0x90, 0xa0)); - p.drawText((w - sfm.horizontalAdvance(m_source)) / 2, topY, m_source); + p.setPen(sourceColor); + p.drawText(qRound(faceCenterX - sfm.horizontalAdvance(m_source) / 2.0), + sourceBaseline, m_source); const float displayDbm = (m_rxMode == RxMode::SMeterPeak) ? m_peakDbm : m_levelDbm; p.setFont(valFont); - p.setPen(QColor(0x00, 0xb4, 0xd8)); + p.setPen(accentColor); // Show S-units based on the displayed value - QString sText; - if (displayDbm <= S0_DBM) sText = "S0"; - else if (displayDbm <= S9_DBM) { - sText = QString("S%1").arg(qBound(0, qRound((displayDbm - S0_DBM) / DB_PER_S), 9)); - } else { - sText = QString("S9+%1").arg(qRound(displayDbm - S9_DBM)); - } - p.drawText(6, topY, sText); + const QString sText = sUnitsTextFor(displayDbm); + p.drawText(qRound(faceLeft) + m_geometry.readout.sideMarginPixels, + valueBaseline, sText); const QString dbmText = QString("%1 dBm").arg(displayDbm, 0, 'f', 0); - p.setPen(QColor(0xc8, 0xd8, 0xe8)); - p.drawText(w - vfm.horizontalAdvance(dbmText) - 6, topY, dbmText); + p.setPen(valueColor); + p.drawText(qRound(faceRight) - vfm.horizontalAdvance(dbmText) + - m_geometry.readout.sideMarginPixels, + valueBaseline, dbmText); } } diff --git a/src/gui/SMeterWidget.h b/src/gui/SMeterWidget.h index a4d04398f..2eddc49ed 100644 --- a/src/gui/SMeterWidget.h +++ b/src/gui/SMeterWidget.h @@ -3,8 +3,14 @@ #include #include #include +#include +#include "AnalogMeterFaceTheme.h" #include "core/KiwiSdrProtocol.h" +#include "RadioSwrValidityFilter.h" +#include "SMeterGeometry.h" + +class QPainter; namespace AetherSDR { @@ -14,7 +20,7 @@ namespace AetherSDR { // S0 = -127 dBm, S1 = -121 dBm, ... S9 = -73 dBm (6 dB per S-unit) // S9+10 = -63 dBm, S9+20 = -53 dBm, S9+40 = -33 dBm, S9+60 = -13 dBm // -// The needle sweeps from S0 (left) to S9+60 (right) across a 180° arc. +// The needle sweeps from S0 (left) to S9+60 (right) across a shallow 70° arc. // Below S9 the scale markings are white; above S9 they are red. class SMeterWidget : public QWidget { Q_OBJECT @@ -22,8 +28,8 @@ class SMeterWidget : public QWidget { public: explicit SMeterWidget(QWidget* parent = nullptr); - QSize sizeHint() const override { return {280, 140}; } - QSize minimumSizeHint() const override { return {200, 100}; } + QSize sizeHint() const override { return m_geometry.sizing.preferred; } + QSize minimumSizeHint() const override { return m_geometry.sizing.minimum; } // Current reading in dBm. float levelDbm() const { return m_levelDbm; } @@ -31,6 +37,16 @@ class SMeterWidget : public QWidget { // Reading as S-units string (e.g. "S7", "S9+20"). QString sUnitsText() const; + const SMeterGeometry& geometry() const { return m_geometry; } + AnalogMeterFaceTheme faceTheme() const { return m_faceTheme; } + QString faceThemeId() const; + QString accessibleValueText() const; + + // Let a detached meter consume its resizable window while preserving the + // compact fixed-height sidebar layout when docked. + void setFloating(bool floating); + void setFaceTheme(AnalogMeterFaceTheme theme); + enum class TxMode { Power, SWR, Level, Compression }; enum class RxMode { SMeter, SMeterPeak }; enum class DecayRate { Fast, Medium, Slow }; @@ -43,6 +59,9 @@ public slots: // Update TX meter values. void setTxMeters(float fwdPower, float swr); + // The radio-native SWR value remains the displayed source. Instantaneous + // forward power is used only to reject samples taken without measurable RF. + void setRadioTxMeters(float fwdPower, float fwdPowerInstant, float swr); // Update mic/compression meter values. void setMicMeters(float micLevel, float compLevel, float micPeak, float compPeak); @@ -73,6 +92,18 @@ public slots: void updatePeakHoldValue(); bool usesUnavailableRxMeter() const; QString unavailableRxMeterLabel() const; + QString sUnitsTextFor(float dbm) const; + void scheduleAccessibleValue(); + void publishAccessibleValue(); + void rebuildBackgroundLayer(); + void drawPhysicalNeedle(QPainter& painter, const QPointF& pivot, + const QPointF& tip, + const QSizeF& faceSize, + const AnalogMeterFaceThemeCatalog::Palette& palette) const; + void drawPhysicalMask(QPainter& painter, const QRectF& face, + const AnalogMeterFaceThemeCatalog::Palette& palette) const; + void finishTxMeterUpdate(); + void updateRadioSwr(float forwardPowerInstant, float swr, qint64 timestampMs); // Map dBm to fraction (0.0 = left, 1.0 = right) for RX S-meter scale float dbmToFraction(float dbm) const; @@ -84,8 +115,18 @@ public slots: float currentTxValue() const; // RX state - float m_levelDbm{-127.0f}; // current RX reading - float m_peakDbm{-127.0f}; // RX peak hold + SMeterGeometry m_geometry; + AnalogMeterFaceThemeCatalog m_faceThemes; + AnalogMeterFaceTheme m_faceTheme{AnalogMeterFaceTheme::AetherDefault}; + QPixmap m_backgroundLayer; + QSize m_backgroundCacheSize; + qreal m_backgroundCacheDpr{0.0}; + AnalogMeterFaceTheme m_backgroundCacheTheme{AnalogMeterFaceTheme::AetherDefault}; + bool m_backgroundCacheValid{false}; + QTimer m_accessibilityTimer; + QString m_lastAccessibleValue; + float m_levelDbm{0.0f}; // current RX reading; initialized from geometry + float m_peakDbm{0.0f}; // RX peak hold; initialized from geometry QString m_source{"S-Meter Peak"}; KiwiSdrProtocol::MeterReading m_receiveMeterReading; bool m_receiveMeterReadingActive{false}; @@ -93,6 +134,8 @@ public slots: // TX meter values (updated continuously, used when transmitting) float m_txPower{0.0f}; float m_txSwr{1.0f}; + RadioSwrValidityFilter m_radioSwrFilter; + QElapsedTimer m_radioSwrClock; float m_micLevel{-50.0f}; // MIC meter — drives Level mode needle float m_micPeak{-50.0f}; // MICPEAK meter — reserved for future peak tick float m_compLevel{0.0f}; @@ -112,20 +155,15 @@ public slots: // Peak hold line state bool m_peakHoldEnabled{false}; - float m_peakHoldDbm{-127.0f}; - float m_peakHoldDecayStartDbm{-127.0f}; + float m_peakHoldDbm{0.0f}; + float m_peakHoldDecayStartDbm{0.0f}; int m_peakHoldTimeMs{1000}; float m_peakDecayDbPerSec{10.0f}; // Medium default QElapsedTimer m_peakHoldTimer; bool m_peakHoldTimerRunning{false}; - // S-unit reference: S0 = -127 dBm, each S-unit = 6 dB - static constexpr float S0_DBM = -127.0f; - static constexpr float S9_DBM = -73.0f; - static constexpr float MAX_DBM = -13.0f; // S9+60 - static constexpr float DB_PER_S = 6.0f; - static constexpr int kNeedleAnimationIntervalMs = 8; + static constexpr int kAccessibilityAnnouncementIntervalMs = 100; static constexpr float kNeedleAttackTimeSeconds = 0.045f; static constexpr float kNeedleReleaseTimeSeconds = 0.180f; static constexpr float kNeedleSnapEpsilon = 0.001f; @@ -134,10 +172,6 @@ public slots: float m_powerScaleMax{120.0f}; float m_powerRedStart{100.0f}; - // Arc geometry: shallow arc spanning ~70° (like SmartSDR) - static constexpr float ARC_START_DEG = 55.0f; // right end (degrees from +X axis) - static constexpr float ARC_END_DEG = 125.0f; // left end - }; } // namespace AetherSDR diff --git a/src/gui/SMeterWidgetAccessible.h b/src/gui/SMeterWidgetAccessible.h new file mode 100644 index 000000000..4b39949fd --- /dev/null +++ b/src/gui/SMeterWidgetAccessible.h @@ -0,0 +1,18 @@ +#pragma once + +#include + +namespace AetherSDR { + +// Accessibility adapter for the custom-painted S-meter. Keeping the adapter +// separate makes the non-visual value contract explicit and discoverable by +// the GUI accessibility check. +class SMeterWidgetAccessible : public QAccessibleWidget { +public: + explicit SMeterWidgetAccessible(QWidget* widget); + QString text(QAccessible::Text textType) const override; +}; + +QAccessibleInterface* sMeterAccessibleFactory(const QString& key, QObject* object); + +} // namespace AetherSDR diff --git a/src/gui/VuMeterSettings.cpp b/src/gui/VuMeterSettings.cpp index 0371b7de3..9310795bc 100644 --- a/src/gui/VuMeterSettings.cpp +++ b/src/gui/VuMeterSettings.cpp @@ -27,6 +27,18 @@ const QStringList& decayItems() return items; } +const QStringList& faceThemeItems() +{ + static const QStringList items{ + kAetherTheme, kClassicTheme, kUplightTheme, kDarkTheme}; + return items; +} + +QString normalizeFaceTheme(const QString& theme) +{ + return faceThemeItems().contains(theme) ? theme : kAetherTheme; +} + QString encode(const Snapshot& settings) { QJsonObject standard; @@ -34,6 +46,7 @@ QString encode(const Snapshot& settings) standard.insert(QStringLiteral("rxSelect"), settings.rxSelect); standard.insert(QStringLiteral("peakHoldEnabled"), settings.peakHoldEnabled); standard.insert(QStringLiteral("peakDecayRate"), settings.peakDecayRate); + standard.insert(QStringLiteral("faceTheme"), normalizeFaceTheme(settings.faceTheme)); QJsonObject root; root.insert(QStringLiteral("version"), kVersion); @@ -65,7 +78,7 @@ Snapshot decode(const QByteArray& encoded, QString* error, const QJsonObject root = document.object(); const int version = root.value(QStringLiteral("version")).toInt(); - if (version != 1 && version != kVersion) { + if (version != 1 && version != 2 && version != kVersion) { if (error) { *error = QStringLiteral("unsupported VuMeter settings version"); } @@ -94,6 +107,10 @@ Snapshot decode(const QByteArray& encoded, QString* error, if (decayItems().contains(decay)) { settings.peakDecayRate = decay; } + if (version >= 3) { + settings.faceTheme = normalizeFaceTheme( + standard.value(QStringLiteral("faceTheme")).toString()); + } return settings; } diff --git a/src/gui/VuMeterSettings.h b/src/gui/VuMeterSettings.h index da7495a1b..217abe221 100644 --- a/src/gui/VuMeterSettings.h +++ b/src/gui/VuMeterSettings.h @@ -6,19 +6,24 @@ namespace AetherSDR::VuMeterSettingsCodec { -inline constexpr int kVersion = 2; +inline constexpr int kVersion = 3; inline const QString kSettingsKey = QStringLiteral("VuMeter"); +inline const QString kAetherTheme = QStringLiteral("aether-default"); +inline const QString kClassicTheme = QStringLiteral("classic-warm"); +inline const QString kUplightTheme = QStringLiteral("dark-room-uplight"); +inline const QString kDarkTheme = QStringLiteral("graphite-dark"); struct Snapshot { int txSelect{0}; int rxSelect{0}; bool peakHoldEnabled{false}; QString peakDecayRate{QStringLiteral("Medium")}; + QString faceTheme{kAetherTheme}; }; // Version 1 temporarily combined the standard and cross-needle meters. Keep // just enough decode metadata to migrate its face theme into the independent -// PWR applet before rewriting VuMeter as the version-2 standard-only object. +// PWR applet before rewriting VuMeter as the current standard-only object. struct LegacyCrossNeedle { bool present{false}; bool selected{false}; @@ -28,6 +33,8 @@ struct LegacyCrossNeedle { const QStringList& txMeterItems(); const QStringList& rxMeterItems(); const QStringList& decayItems(); +const QStringList& faceThemeItems(); +QString normalizeFaceTheme(const QString& theme); QString encode(const Snapshot& settings); Snapshot decode(const QByteArray& encoded, QString* error = nullptr, diff --git a/tests/TestSettingsProfile.h b/tests/TestSettingsProfile.h new file mode 100644 index 000000000..c59e4d8af --- /dev/null +++ b/tests/TestSettingsProfile.h @@ -0,0 +1,48 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +// Must be constructed before QApplication/QCoreApplication and before the +// first AppSettings or ThemeManager access. Qt does not honor the same config +// environment variable on every supported platform, so redirect all relevant +// homes and enable QStandardPaths test mode as a second layer. +class TestSettingsProfile +{ +public: + explicit TestSettingsProfile(const QString& testName) + : m_root(QDir::tempPath() + QStringLiteral("/") + testName + + QStringLiteral("-XXXXXX")) + { + if (!m_root.isValid()) { + return; + } + + const QByteArray root = m_root.path().toUtf8(); + qputenv("HOME", root); + qputenv("CFFIXED_USER_HOME", root); + qputenv("XDG_CONFIG_HOME", root); + qputenv("LOCALAPPDATA", root); + qputenv("APPDATA", root); + QStandardPaths::setTestModeEnabled(true); + + // AppSettings first-run migration still probes the legacy QSettings + // store. On macOS the native CFPreferences backend can ignore HOME, + // so force that probe into a private INI directory as well. + const QString legacyRoot = m_root.path() + QStringLiteral("/legacy-settings"); + QDir().mkpath(legacyRoot); + QSettings::setDefaultFormat(QSettings::IniFormat); + QSettings::setPath(QSettings::IniFormat, QSettings::UserScope, legacyRoot); + QSettings::setPath(QSettings::NativeFormat, QSettings::UserScope, legacyRoot); + } + + bool isValid() const { return m_root.isValid(); } + QString path() const { return m_root.path(); } + +private: + QTemporaryDir m_root; +}; diff --git a/tests/amp_applet_test.cpp b/tests/amp_applet_test.cpp index 046f4283d..414d5bac9 100644 --- a/tests/amp_applet_test.cpp +++ b/tests/amp_applet_test.cpp @@ -1,3 +1,4 @@ +#include "TestSettingsProfile.h" #include "core/AppSettings.h" #include "gui/AmpApplet.h" @@ -39,6 +40,7 @@ void resetSettings() QFile::remove(path); QFile::remove(path + QStringLiteral(".bak")); QFile::remove(path + QStringLiteral(".tmp")); + settings.load(); } void testDefaultPlaceholder() @@ -136,28 +138,17 @@ void testPreferenceReload() int main(int argc, char** argv) { - QTemporaryDir fakeHome(QDir::tempPath() + "/aether-amp-applet-test-XXXXXX"); - if (!fakeHome.isValid()) { + TestSettingsProfile settingsProfile(QStringLiteral("aether-amp-applet-test")); + if (!settingsProfile.isValid()) { std::printf("[FAIL] create temporary home\n"); return 1; } - - const QByteArray fakeHomePath = fakeHome.path().toUtf8(); - qputenv("HOME", fakeHomePath); - qputenv("CFFIXED_USER_HOME", fakeHomePath); - qputenv("LOCALAPPDATA", fakeHomePath); - qputenv("XDG_CONFIG_HOME", fakeHomePath); if (qEnvironmentVariableIsEmpty("QT_QPA_PLATFORM")) { qputenv("QT_QPA_PLATFORM", "offscreen"); } - QStandardPaths::setTestModeEnabled(true); QApplication app(argc, argv); - const QString configRoot = - QStandardPaths::writableLocation(QStandardPaths::GenericConfigLocation); - QDir(configRoot + QStringLiteral("/AetherSDR")).removeRecursively(); - std::printf("AmpApplet temperature unit test harness\n\n"); testDefaultPlaceholder(); diff --git a/tests/antenna_alias_test.cpp b/tests/antenna_alias_test.cpp index 14111b75b..8c3bc0767 100644 --- a/tests/antenna_alias_test.cpp +++ b/tests/antenna_alias_test.cpp @@ -1,3 +1,4 @@ +#include "TestSettingsProfile.h" #include "core/AppSettings.h" #include "models/AntennaAliasStore.h" #include "models/SliceModel.h" @@ -24,22 +25,15 @@ bool expect(bool condition, const char* label) int main(int argc, char** argv) { - QTemporaryDir fakeHome(QDir::tempPath() + "/aether-antenna-alias-test-XXXXXX"); - if (!fakeHome.isValid()) { + TestSettingsProfile settingsProfile(QStringLiteral("aether-antenna-alias-test")); + if (!settingsProfile.isValid()) { std::cerr << "[FAIL] create temporary home\n"; return 1; } - qputenv("HOME", fakeHome.path().toUtf8()); - qputenv("CFFIXED_USER_HOME", fakeHome.path().toUtf8()); - QStandardPaths::setTestModeEnabled(true); QCoreApplication app(argc, argv); - const QString configRoot = - QStandardPaths::writableLocation(QStandardPaths::ConfigLocation); - QDir(configRoot + "/AetherSDR").removeRecursively(); - auto& settings = AppSettings::instance(); - settings.reset(); + settings.load(); const QString radioKey = QStringLiteral("serial-123"); QMap aliases; @@ -107,6 +101,5 @@ int main(int argc, char** argv) ok &= expect(commands == QStringList({QStringLiteral("slice set 3 txant=XVTR")}), "slice TX antenna command keeps canonical token"); - QDir(configRoot + "/AetherSDR").removeRecursively(); return ok ? 0 : 1; } diff --git a/tests/app_settings_safety_test.cpp b/tests/app_settings_safety_test.cpp new file mode 100644 index 000000000..ae8e07c6e --- /dev/null +++ b/tests/app_settings_safety_test.cpp @@ -0,0 +1,364 @@ +#include "TestSettingsProfile.h" +#include "core/AppSettings.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +using namespace AetherSDR; + +namespace { + +int g_failures = 0; + +void expect(bool condition, const char* description) +{ + std::printf("%s %s\n", condition ? "[ OK ]" : "[FAIL]", description); + if (!condition) { + ++g_failures; + } +} + +QString settingsPath() +{ + return QStandardPaths::writableLocation(QStandardPaths::GenericConfigLocation) + + QStringLiteral("/AetherSDR/AetherSDR.settings"); +} + +QByteArray settingsDocument(int count, const QString& valuePrefix) +{ + QByteArray data; + QBuffer buffer(&data); + buffer.open(QIODevice::WriteOnly); + QXmlStreamWriter xml(&buffer); + xml.setAutoFormatting(true); + xml.writeStartDocument(); + xml.writeStartElement(QStringLiteral("Settings")); + for (int index = 0; index < count; ++index) { + xml.writeTextElement( + QStringLiteral("Key%1").arg(index, 3, 10, QLatin1Char('0')), + QStringLiteral("%1-%2").arg(valuePrefix).arg(index)); + } + xml.writeEndElement(); + xml.writeEndDocument(); + buffer.close(); + return data; +} + +bool writeFile(const QString& path, const QByteArray& data) +{ + QDir().mkpath(QFileInfo(path).absolutePath()); + QFile file(path); + if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + return false; + } + return file.write(data) == data.size(); +} + +QByteArray readFile(const QString& path) +{ + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) { + return {}; + } + return file.readAll(); +} + +QMap parseSettings(const QByteArray& data, bool* valid) +{ + QMap values; + QXmlStreamReader xml(data); + while (!xml.atEnd()) { + xml.readNext(); + if (xml.isStartElement() && xml.name() != QStringLiteral("Settings")) { + const QString key = xml.name().toString(); + values.insert(key, xml.readElementText()); + } + } + *valid = !xml.hasError(); + return values; +} + +void testSaveBeforeLoad() +{ + const QString path = settingsPath(); + const QByteArray live = settingsDocument(50, QStringLiteral("live")); + const QByteArray backup = settingsDocument(40, QStringLiteral("backup")); + expect(writeFile(path, live), "production-like live fixture is written"); + expect(writeFile(path + QStringLiteral(".bak"), backup), + "production-like backup fixture is written"); + + AppSettings& settings = AppSettings::instance(); + settings.setValue(QStringLiteral("OnlyNewKey"), QStringLiteral("new-value")); + settings.save(); + + expect(readFile(path) == live, + "save-before-load leaves the existing live file byte-for-byte unchanged"); + expect(readFile(path + QStringLiteral(".bak")) == backup, + "save-before-load does not rotate or replace the backup"); + expect(!QFile::exists(path + QStringLiteral(".tmp")), + "save-before-load creates no temporary settings file"); +} + +void testNormalLoadThenSave() +{ + const QString path = settingsPath(); + const QByteArray original = settingsDocument(50, QStringLiteral("original")); + expect(writeFile(path, original), "normal-load fixture is written"); + + AppSettings& settings = AppSettings::instance(); + settings.load(); + expect(settings.value(QStringLiteral("Key049")).toString() + == QStringLiteral("original-49"), + "normal load reads the complete existing profile"); + settings.setValue(QStringLiteral("AddedKey"), QStringLiteral("preserved")); + settings.save(); + + bool valid = false; + const QMap saved = parseSettings(readFile(path), &valid); + expect(valid, "normal load-then-save produces valid XML"); + expect(saved.size() == 51 && saved.value(QStringLiteral("Key000")) + == QStringLiteral("original-0") + && saved.value(QStringLiteral("Key049")) + == QStringLiteral("original-49") + && saved.value(QStringLiteral("AddedKey")) + == QStringLiteral("preserved"), + "normal load-then-save preserves every old key and the new key"); + expect(readFile(path + QStringLiteral(".bak")) == original, + "normal save retains the prior live profile as its backup"); +} + +void testFirstRunInitialization() +{ + const QString path = settingsPath(); + AppSettings& settings = AppSettings::instance(); + settings.load(); + + bool valid = false; + const QMap saved = parseSettings(readFile(path), &valid); + expect(valid && QFile::exists(path), + "first-run load creates a valid settings document"); + expect(saved.size() == 13 + && saved.value(QStringLiteral("AutoConnect")) == QStringLiteral("True") + && saved.value(QStringLiteral("AutoConnectToLastRadio")) + == QStringLiteral("True") + && saved.contains(QStringLiteral("GUIClientID")), + "first-run initialization writes only the intentional default profile"); +} + +void testSettingCountGuard() +{ + const QString path = settingsPath(); + const QByteArray original = settingsDocument(50, QStringLiteral("guarded")); + expect(writeFile(path, original), "setting-count guard fixture is written"); + + AppSettings& settings = AppSettings::instance(); + settings.load(); + for (int index = 0; index < 40; ++index) { + settings.remove( + QStringLiteral("Key%1").arg(index, 3, 10, QLatin1Char('0'))); + } + settings.save(); + + expect(readFile(path) == original, + "the existing setting-count guard still rejects a truncated save"); + expect(!QFile::exists(path + QStringLiteral(".bak")), + "a setting-count rejection does not rotate the live profile"); +} + +void testBackupRecovery() +{ + const QString path = settingsPath(); + const QByteArray corruptLive("partial"); + const QByteArray goodBackup = settingsDocument(50, QStringLiteral("backup")); + expect(writeFile(path, corruptLive), "corrupt live fixture is written"); + expect(writeFile(path + QStringLiteral(".bak"), goodBackup), + "valid recovery fixture is written"); + + AppSettings& settings = AppSettings::instance(); + settings.load(); + expect(settings.value(QStringLiteral("Key049")).toString() + == QStringLiteral("backup-49"), + "a corrupt live profile recovers all values from a valid backup"); + settings.setValue(QStringLiteral("RecoveredKey"), QStringLiteral("yes")); + settings.save(); + + bool valid = false; + const QMap saved = parseSettings(readFile(path), &valid); + expect(valid && saved.size() == 51 + && saved.value(QStringLiteral("Key000")) == QStringLiteral("backup-0") + && saved.value(QStringLiteral("RecoveredKey")) == QStringLiteral("yes"), + "a recovered profile may be safely saved without losing backup keys"); + expect(readFile(path + QStringLiteral(".bak")) == goodBackup, + "saving a recovered profile preserves the known-good backup"); +} + +void testMissingLivePromotesValidTemp() +{ + const QString path = settingsPath(); + const QByteArray pending = settingsDocument(50, QStringLiteral("pending")); + const QByteArray backup = settingsDocument(40, QStringLiteral("backup")); + expect(writeFile(path + QStringLiteral(".tmp"), pending), + "interrupted-commit temporary fixture is written"); + expect(writeFile(path + QStringLiteral(".bak"), backup), + "interrupted-commit backup fixture is written"); + + AppSettings& settings = AppSettings::instance(); + settings.load(); + + expect(settings.value(QStringLiteral("Key049")).toString() + == QStringLiteral("pending-49"), + "a valid pending commit wins when the live profile is missing"); + expect(readFile(path) == pending, + "a valid pending commit is promoted to the live settings path"); + expect(!QFile::exists(path + QStringLiteral(".tmp")), + "promoting a pending commit consumes the temporary file"); + expect(readFile(path + QStringLiteral(".bak")) == backup, + "promoting a pending commit preserves the prior known-good backup"); + + settings.setValue(QStringLiteral("RecoveredKey"), QStringLiteral("yes")); + settings.save(); + + bool valid = false; + const QMap saved = parseSettings(readFile(path), &valid); + expect(valid && saved.size() == 51 + && saved.value(QStringLiteral("Key000")) + == QStringLiteral("pending-0") + && saved.value(QStringLiteral("RecoveredKey")) + == QStringLiteral("yes"), + "saving a promoted pending commit preserves its complete state"); + expect(readFile(path + QStringLiteral(".bak")) == backup, + "the first save after promotion preserves the known-good recovery backup"); +} + +void testMissingLiveRecoversBackup() +{ + const QString path = settingsPath(); + const QByteArray corruptTemp("partial"); + const QByteArray backup = settingsDocument(50, QStringLiteral("backup-only")); + expect(writeFile(path + QStringLiteral(".tmp"), corruptTemp), + "invalid newer temporary recovery fixture is written"); + expect(writeFile(path + QStringLiteral(".bak"), backup), + "missing-live backup fixture is written"); + + AppSettings& settings = AppSettings::instance(); + settings.load(); + expect(settings.value(QStringLiteral("Key049")).toString() + == QStringLiteral("backup-only-49"), + "a missing live profile falls back from an invalid temp to its valid backup"); + + settings.setValue(QStringLiteral("RecoveredKey"), QStringLiteral("yes")); + settings.save(); + + bool valid = false; + const QMap saved = parseSettings(readFile(path), &valid); + expect(valid && saved.size() == 51 + && saved.value(QStringLiteral("Key000")) + == QStringLiteral("backup-only-0") + && saved.value(QStringLiteral("RecoveredKey")) + == QStringLiteral("yes"), + "saving a missing-live recovery restores a complete live profile"); + expect(readFile(path + QStringLiteral(".bak")) == backup, + "saving a missing-live recovery preserves the known-good backup"); +} + +void testMissingLiveCorruptRecoveryFailsClosed() +{ + const QString path = settingsPath(); + const QByteArray corruptTemp("partial"); + const QByteArray corruptBackup("partial"); + expect(writeFile(path + QStringLiteral(".tmp"), corruptTemp), + "corrupt missing-live temporary fixture is written"); + expect(writeFile(path + QStringLiteral(".bak"), corruptBackup), + "corrupt missing-live backup fixture is written"); + + AppSettings& settings = AppSettings::instance(); + settings.load(); + settings.setValue(QStringLiteral("Replacement"), QStringLiteral("must-not-save")); + settings.save(); + + expect(!QFile::exists(path), + "invalid missing-live recovery artifacts do not create a default profile"); + expect(readFile(path + QStringLiteral(".tmp")) == corruptTemp, + "failed missing-live recovery leaves the temporary artifact unchanged"); + expect(readFile(path + QStringLiteral(".bak")) == corruptBackup, + "failed missing-live recovery leaves the backup artifact unchanged"); +} + +void testFailedLoadCannotSave() +{ + const QString path = settingsPath(); + QByteArray corruptLive(""); + for (int index = 0; index < 25; ++index) { + corruptLive += "value"; + } + corruptLive += ""; + const QByteArray corruptBackup(""); + expect(writeFile(path, corruptLive), "partially populated corrupt live fixture is written"); + expect(writeFile(path + QStringLiteral(".bak"), corruptBackup), + "corrupt backup fixture is written"); + + AppSettings& settings = AppSettings::instance(); + settings.load(); + settings.setValue(QStringLiteral("Replacement"), QStringLiteral("must-not-save")); + settings.save(); + + expect(readFile(path) == corruptLive, + "failed main and backup loads leave the live file unchanged"); + expect(readFile(path + QStringLiteral(".bak")) == corruptBackup, + "failed main and backup loads leave the backup unchanged"); + expect(!QFile::exists(path + QStringLiteral(".tmp")), + "failed loads cannot create a replacement temporary file"); +} + +} // namespace + +int main(int argc, char** argv) +{ + TestSettingsProfile profile(QStringLiteral("aether-app-settings-safety-test")); + if (!profile.isValid()) { + std::fprintf(stderr, "[FAIL] could not create temporary settings profile\n"); + return 1; + } + + QCoreApplication app(argc, argv); + if (argc != 2) { + std::fprintf(stderr, "usage: app_settings_safety_test \n"); + return 2; + } + + const QString scenario = QString::fromLocal8Bit(argv[1]); + if (scenario == QStringLiteral("save-before-load")) { + testSaveBeforeLoad(); + } else if (scenario == QStringLiteral("load-then-save")) { + testNormalLoadThenSave(); + } else if (scenario == QStringLiteral("first-run")) { + testFirstRunInitialization(); + } else if (scenario == QStringLiteral("count-guard")) { + testSettingCountGuard(); + } else if (scenario == QStringLiteral("backup-recovery")) { + testBackupRecovery(); + } else if (scenario == QStringLiteral("missing-live-temp")) { + testMissingLivePromotesValidTemp(); + } else if (scenario == QStringLiteral("missing-live-backup")) { + testMissingLiveRecoversBackup(); + } else if (scenario == QStringLiteral("missing-live-corrupt")) { + testMissingLiveCorruptRecoveryFailsClosed(); + } else if (scenario == QStringLiteral("failed-load")) { + testFailedLoadCannotSave(); + } else { + std::fprintf(stderr, "unknown scenario: %s\n", argv[1]); + return 2; + } + + return g_failures == 0 ? 0 : 1; +} diff --git a/tests/container_manager_test.cpp b/tests/container_manager_test.cpp index c4f0e0d8c..592b9b916 100644 --- a/tests/container_manager_test.cpp +++ b/tests/container_manager_test.cpp @@ -1,6 +1,7 @@ // Phase 2 tests — ContainerManager lifecycle, factory, persistence. // Headless via QApplication with offscreen platform. +#include "TestSettingsProfile.h" #include "gui/containers/ContainerManager.h" #include "gui/containers/ContainerWidget.h" #include "core/AppSettings.h" @@ -137,7 +138,12 @@ void testSaveRestoreRoundTrip() int main(int argc, char** argv) { + TestSettingsProfile settingsProfile(QStringLiteral("aether-container-manager-test")); + if (!settingsProfile.isValid()) { + return 1; + } QApplication app(argc, argv); + AppSettings::instance().load(); std::printf("Container system Phase 2 manager tests\n\n"); testCreateAndLookup(); diff --git a/tests/container_nesting_test.cpp b/tests/container_nesting_test.cpp index 9af3f2394..01d3b8554 100644 --- a/tests/container_nesting_test.cpp +++ b/tests/container_nesting_test.cpp @@ -3,6 +3,7 @@ // drives a specific sequence of manager calls, and asserts the // resulting state. +#include "TestSettingsProfile.h" #include "gui/containers/ContainerManager.h" #include "gui/containers/ContainerWidget.h" #include "core/AppSettings.h" @@ -241,7 +242,12 @@ void test_reparent() int main(int argc, char** argv) { + TestSettingsProfile settingsProfile(QStringLiteral("aether-container-nesting-test")); + if (!settingsProfile.isValid()) { + return 1; + } QApplication app(argc, argv); + AppSettings::instance().load(); std::printf("Container system Phase 3 nesting tests\n\n"); test_1_floatParentWhileChildFloating(); diff --git a/tests/container_widget_test.cpp b/tests/container_widget_test.cpp index 3c23c6148..69eb08123 100644 --- a/tests/container_widget_test.cpp +++ b/tests/container_widget_test.cpp @@ -5,6 +5,8 @@ // process signals, but never calls show() (no X11 / display needed). // Run: ./build/container_widget_test +#include "TestSettingsProfile.h" +#include "core/AppSettings.h" #include "gui/containers/ContainerTitleBar.h" #include "gui/containers/ContainerWidget.h" #include "gui/containers/FloatingContainerWindow.h" @@ -209,7 +211,12 @@ void testTitlebarCloseButtonToggle() int main(int argc, char** argv) { + TestSettingsProfile settingsProfile(QStringLiteral("aether-container-widget-test")); + if (!settingsProfile.isValid()) { + return 1; + } QApplication app(argc, argv); + AppSettings::instance().load(); std::printf("Container system Phase 1 test harness\n\n"); testContainerBasics(); diff --git a/tests/cross_needle_meter_test.cpp b/tests/cross_needle_meter_test.cpp index aada9ea04..6b2669501 100644 --- a/tests/cross_needle_meter_test.cpp +++ b/tests/cross_needle_meter_test.cpp @@ -1,6 +1,7 @@ // Deterministic construction and mechanics regression test for the PWR // applet's cross-needle Forward/Reflected power and SWR face. +#include "TestSettingsProfile.h" #include "gui/CrossNeedleMeterGeometry.h" #include "gui/CrossNeedleMeterWidget.h" @@ -9,6 +10,7 @@ #include #include #include +#include #include #include @@ -82,6 +84,22 @@ int differingPixels(const QImage &first, const QImage &second) { return count; } +int differingPixels(const QImage &first, const QImage &second, const QRect &area) { + if (first.size() != second.size()) { + return -1; + } + const QRect clipped = area.intersected(first.rect()); + int count = 0; + for (int y = clipped.top(); y <= clipped.bottom(); ++y) { + for (int x = clipped.left(); x <= clipped.right(); ++x) { + if (first.pixel(x, y) != second.pixel(x, y)) { + ++count; + } + } + } + return count; +} + double meanLuminance(const QImage &image, const QRect &area) { const QRect clipped = area.intersected(image.rect()); if (clipped.isEmpty()) { @@ -111,30 +129,135 @@ double distanceToSegment(const QPointF &point, const QPointF &start, const QPoin return std::hypot(point.x() - nearest.x(), point.y() - nearest.y()); } +double distanceToPathOrUpperContinuation(const QPointF &point, const QPainterPath &path) { + if (path.isEmpty()) { + return std::numeric_limits::infinity(); + } + double distance = std::numeric_limits::infinity(); + for (int element = 1; element < path.elementCount(); ++element) { + distance = std::min(distance, + distanceToSegment(point, path.elementAt(element - 1), + path.elementAt(element))); + } + if (path.elementCount() >= 2) { + const QPointF end = path.currentPosition(); + QPointF direction = end - QPointF(path.elementAt(path.elementCount() - 2)); + const double length = std::hypot(direction.x(), direction.y()); + if (length > 1e-12) { + direction /= length; + const double projection = std::max(0.0, QPointF::dotProduct(point - end, direction)); + const QPointF nearest = end + direction * projection; + distance = std::min(distance, + std::hypot(point.x() - nearest.x(), point.y() - nearest.y())); + } + } + return distance; +} + QPointF pointOnScale(const CrossNeedleMeterGeometry::Scale &scale, double angle) { return scale.center + QPointF(std::cos(angle), std::sin(angle)) * scale.radius; } -const CrossNeedleMeterGeometry::SwrGuide *findGuide( - const CrossNeedleMeterGeometry &geometry, const QString &label) { - const auto guide = std::find_if( - geometry.swrGuides.cbegin(), geometry.swrGuides.cend(), - [&label](const CrossNeedleMeterGeometry::SwrGuide &candidate) { - return candidate.label == label; - }); - return guide == geometry.swrGuides.cend() ? nullptr : &*guide; +double targetPowerRatio(const CrossNeedleMeterGeometry::SwrGuide &guide) { + if (std::isinf(guide.swr)) { + return 1.0; + } + const double reflectionCoefficient = (guide.swr - 1.0) / (guide.swr + 1.0); + return reflectionCoefficient * reflectionCoefficient; +} + +QPointF pointOnPathAtY(const QPainterPath &path, double y) { + if (path.isEmpty()) { + return {}; + } + QPointF closest = path.elementAt(0); + double closestDistance = std::abs(closest.y() - y); + for (int index = 1; index < path.elementCount(); ++index) { + const QPointF first = path.elementAt(index - 1); + const QPointF second = path.elementAt(index); + const double firstOffset = first.y() - y; + const double secondOffset = second.y() - y; + if (firstOffset * secondOffset <= 0.0 && first.y() != second.y()) { + const double fraction = (y - first.y()) / (second.y() - first.y()); + return first + (second - first) * fraction; + } + if (std::abs(secondOffset) < closestDistance) { + closest = second; + closestDistance = std::abs(secondOffset); + } + } + return closest; +} + +double maskBoundaryYAtX(const CrossNeedleMeterGeometry &geometry, double x) { + const QVector &boundary = geometry.mask.boundary; + if (boundary.isEmpty()) { + return geometry.mask.bottomY; + } + if (x <= boundary.first().x()) { + return boundary.first().y(); + } + for (int index = 1; index < boundary.size(); ++index) { + const QPointF first = boundary[index - 1]; + const QPointF second = boundary[index]; + if (x <= second.x()) { + const double span = second.x() - first.x(); + const double fraction = span > 0.0 ? (x - first.x()) / span : 0.0; + return first.y() + (second.y() - first.y()) * fraction; + } + } + return boundary.last().y(); } void testResourceAndConstruction() { QString error; const CrossNeedleMeterGeometry geometry = CrossNeedleMeterGeometry::loadResource(&error); - report("V12 JSON resource validates", geometry.isValid(), error.toStdString()); + report("semantic cross-needle JSON resource validates", geometry.isValid(), + error.toStdString()); + report("compiled degraded fallback remains mechanically valid", + CrossNeedleMeterGeometry::fallback().isValid()); if (!geometry.isValid()) { return; } - report("geometry format is versioned", geometry.formatVersion == 1); - report("approved design version is retained", geometry.designVersion == 12); + CrossNeedleMeterGeometry impossibleLabelGeometry = geometry; + impossibleLabelGeometry.swrGuides = {geometry.swrGuides.first()}; + impossibleLabelGeometry.typography.swrNumberPixels = 2000; + QString impossibleLabelError; + report("resource validation rejects an unsatisfiable SWR label layout", + !impossibleLabelGeometry.isValid(&impossibleLabelError) && + impossibleLabelError.contains(QStringLiteral("label")), + impossibleLabelError.toStdString()); + + const auto rejectsMask = [&geometry](CrossNeedleMeterGeometry candidate, + const char *description) { + QString maskError; + report(description, + !candidate.isValid(&maskError) && + maskError.contains(QStringLiteral("lower mask")), + maskError.toStdString()); + }; + CrossNeedleMeterGeometry unsortedMask = geometry; + std::swap(unsortedMask.mask.boundary[1], unsortedMask.mask.boundary[2]); + rejectsMask(unsortedMask, "resource validation rejects an unsorted lower mask"); + CrossNeedleMeterGeometry duplicateMaskX = geometry; + duplicateMaskX.mask.boundary[1].setX(duplicateMaskX.mask.boundary[0].x()); + rejectsMask(duplicateMaskX, "resource validation rejects duplicate mask x coordinates"); + CrossNeedleMeterGeometry outOfBoundsMask = geometry; + outOfBoundsMask.mask.boundary[0].setX(-1.0); + rejectsMask(outOfBoundsMask, "resource validation rejects an out-of-bounds lower mask"); + CrossNeedleMeterGeometry asymmetricMask = geometry; + asymmetricMask.mask.boundary[0].setY( + asymmetricMask.mask.boundary[0].y() - 1.0); + rejectsMask(asymmetricMask, "resource validation rejects an asymmetric lower mask"); + CrossNeedleMeterGeometry nonFiniteMask = geometry; + nonFiniteMask.mask.boundary[0].setY( + std::numeric_limits::infinity()); + rejectsMask(nonFiniteMask, "resource validation rejects a non-finite lower mask"); + + report("semantic geometry format is versioned", geometry.formatVersion == 6); + report("angled-rest concave response design revision is retained", + geometry.designVersion == 19); report("face material keeps editable deterministic gradient layers", geometry.faceGradient.middleStop > 0.0 && geometry.faceGradient.middleStop < 1.0 && geometry.faceGradient.glowRadius > 0.0 && @@ -218,6 +341,121 @@ void testResourceAndConstruction() { geometry.forwardScale.values.size() == 16); report("reflected graph retains 16 calibrated major ticks", geometry.reflectedScale.values.size() == 16); + double maximumRuntimeTickErrorPixels = 0.0; + double maximumReferenceFitErrorPixels = 0.0; + bool referenceFitsWithinDeclaredBudget = true; + for (int index = 0; index < geometry.forwardScale.values.size(); ++index) { + const double angle = + geometry.forwardAngle(geometry.forwardScale.values[index], 1.0); + maximumRuntimeTickErrorPixels = + std::max(maximumRuntimeTickErrorPixels, + std::abs(angle - geometry.forwardScale.anglesRadians[index]) * + geometry.forwardScale.radius); + if (index > 0) { + const double referenceError = + std::abs(angle - geometry.forwardScale.referenceAnglesRadians[index]) * + geometry.forwardScale.radius; + maximumReferenceFitErrorPixels = + std::max(maximumReferenceFitErrorPixels, referenceError); + referenceFitsWithinDeclaredBudget = + referenceFitsWithinDeclaredBudget && + referenceError <= geometry.forwardScale.maximumReferenceErrorPixels; + } + } + for (int index = 0; index < geometry.reflectedScale.values.size(); ++index) { + const double angle = + geometry.reflectedAngle(geometry.reflectedScale.values[index], 1.0); + maximumRuntimeTickErrorPixels = + std::max(maximumRuntimeTickErrorPixels, + std::abs(angle - geometry.reflectedScale.anglesRadians[index]) * + geometry.reflectedScale.radius); + if (index > 0) { + const double referenceError = + std::abs(angle - geometry.reflectedScale.referenceAnglesRadians[index]) * + geometry.reflectedScale.radius; + maximumReferenceFitErrorPixels = + std::max(maximumReferenceFitErrorPixels, referenceError); + referenceFitsWithinDeclaredBudget = + referenceFitsWithinDeclaredBudget && + referenceError <= geometry.reflectedScale.maximumReferenceErrorPixels; + } + } + const auto responseIsMonotone = [](const CrossNeedleMeterGeometry::Scale &scale) { + for (int index = 1; index < scale.responseCoefficients.size(); ++index) { + if (scale.responseCoefficients[index] + 1e-12 < + scale.responseCoefficients[index - 1]) { + return false; + } + } + return true; + }; + const auto responseIsConcave = [](const CrossNeedleMeterGeometry::Scale &scale) { + for (int index = 0; index + 2 < scale.responseCoefficients.size(); ++index) { + if (scale.responseCoefficients[index + 2] - + 2.0 * scale.responseCoefficients[index + 1] + + scale.responseCoefficients[index] > + 1e-12) { + return false; + } + } + return true; + }; + report("both movements use a smooth monotonic concave response", + geometry.forwardScale.responseModel == QStringLiteral("concave_bernstein_v1") && + geometry.reflectedScale.responseModel == QStringLiteral("concave_bernstein_v1") && + geometry.forwardScale.responseCoefficients.size() == 6 && + geometry.reflectedScale.responseCoefficients.size() == 6 && + responseIsMonotone(geometry.forwardScale) && + responseIsMonotone(geometry.reflectedScale) && + responseIsConcave(geometry.forwardScale) && + responseIsConcave(geometry.reflectedScale)); + report("calibrated positive-power ticks use the exact live movement response", + maximumRuntimeTickErrorPixels <= 1e-8, + "max_error_px=" + std::to_string(maximumRuntimeTickErrorPixels)); + report("needles park on their printed zero mark (angled rest on the dial)", + near(CrossNeedleMeterGeometry::printedAngleForIndex(geometry.forwardScale, 0), + geometry.forwardScale.anglesRadians[0], 1e-12) && + near(geometry.forwardScale.anglesRadians[0], + geometry.forwardScale.responseStartRadians, 1e-12) && + near(CrossNeedleMeterGeometry::printedAngleForIndex(geometry.reflectedScale, 0), + geometry.reflectedScale.anglesRadians[0], 1e-12) && + near(geometry.reflectedScale.anglesRadians[0], + geometry.reflectedScale.responseStartRadians, 1e-12)); + report("constrained movement fits retain the photographed scale observations", + referenceFitsWithinDeclaredBudget, + "max_reference_error_px=" + + std::to_string(maximumReferenceFitErrorPixels)); + + double maximumSecondDerivativeJump = 0.0; + const auto measureSecondDerivativeJumps = + [&maximumSecondDerivativeJump](const CrossNeedleMeterGeometry::Scale &scale, + const auto &angleAt) { + const double step = scale.values.last() * 1e-4; + for (int index = 1; index + 1 < scale.values.size(); ++index) { + const double value = scale.values[index]; + const double atKnot = angleAt(value); + const double leftSecond = + (atKnot - 2.0 * angleAt(value - step) + + angleAt(value - 2.0 * step)) / + (step * step); + const double rightSecond = + (angleAt(value + 2.0 * step) - + 2.0 * angleAt(value + step) + atKnot) / + (step * step); + maximumSecondDerivativeJump = + std::max(maximumSecondDerivativeJump, + std::abs(rightSecond - leftSecond)); + } + }; + measureSecondDerivativeJumps( + geometry.forwardScale, + [&geometry](double value) { return geometry.forwardAngle(value, 1.0); }); + measureSecondDerivativeJumps( + geometry.reflectedScale, + [&geometry](double value) { return geometry.reflectedAngle(value, 1.0); }); + report("movement second derivatives stay continuous across calibration knots", + maximumSecondDerivativeJump <= 0.01, + "max_jump=" + std::to_string(maximumSecondDerivativeJump)); report("forward graph retains five-way minor subdivision", geometry.forwardScale.minorSubdivisions == 5); report("reflected graph retains two-way minor subdivision", @@ -252,84 +490,445 @@ void testResourceAndConstruction() { geometry.typography.sideTitlePixels >= 50 && geometry.typography.swrNumberPixels >= 36 && geometry.typography.rangePixels >= 26 && geometry.typography.unitPixels >= 40 && geometry.typography.maskLabelPixels >= 46); - report("SWR fan retains all 12 source guides", geometry.swrGuides.size() == 12); - int curvedGuideCount = 0; - int straightGuideCount = 0; - int cubicGuideCount = 0; - bool tangentContinuous = true; - bool registeredDatumsRetained = true; - double maximumTopError = 0.0; - double maximumVisibleBulge = 0.0; - double minimumHiddenX = std::numeric_limits::infinity(); - double maximumHiddenX = -std::numeric_limits::infinity(); - for (const CrossNeedleMeterGeometry::SwrGuide &guide : geometry.swrGuides) { + const QStringList expectedGuideOrder = { + QStringLiteral("infinity"), QStringLiteral("8"), QStringLiteral("4"), + QStringLiteral("3"), QStringLiteral("2.5"), QStringLiteral("2"), + QStringLiteral("1.7"), QStringLiteral("1.5"), QStringLiteral("1.4"), + QStringLiteral("1.3"), QStringLiteral("1.2"), QStringLiteral("1.1")}; + report("SWR fan retains all 12 semantic constant-ratio guides", + geometry.swrGuides.size() == expectedGuideOrder.size()); + report("SWR construction exposes editable clearance, gap, and sampling", + geometry.swrStyle.graphClearance > 0.0 && + geometry.swrStyle.graphClearance < + std::min(geometry.forwardScale.radius, geometry.reflectedScale.radius) && + geometry.swrStyle.maskGap >= 0.0 && geometry.swrStyle.curveSamples >= 96); + + bool guideOrderCorrect = geometry.swrGuides.size() == expectedGuideOrder.size(); + bool everyLabelVisible = true; + bool sampledPaths = true; + bool contoursShareConvergence = true; + QPointF convergencePoint; + double maximumConvergenceSpread = 0.0; + QVector visibleLowerAngleDeg(geometry.swrGuides.size(), -1.0); + QVector visibleSampleCount(geometry.swrGuides.size(), 0); + bool mechanicallyCalibrated = true; + bool labelsNearestOwnGuide = true; + QStringList misassociatedLabels; + bool orderedAboveMask = true; + double maximumEnvelopeGapError = 0.0; + double maximumRatioError = 0.0; + double maximumSegmentRatioError = 0.0; + double maximumPathApproximationError = 0.0; + double maximumTurnRadians = 0.0; + double maximumCurvatureStep = 0.0; + int totalCurvatureReversals = 0; + int maximumGuideCurvatureReversals = 0; + double maximumCurveBow = 0.0; + double minimumLabelSpacing = std::numeric_limits::infinity(); + double minimumOwnGuideMargin = std::numeric_limits::infinity(); + double previousConstructionX = -std::numeric_limits::infinity(); + QVector labelCenters; + QVector labelRects; + QFont swrLabelFont = QApplication::font(); + swrLabelFont.setPixelSize(geometry.typography.swrNumberPixels); + swrLabelFont.setBold(true); + const QFontMetricsF swrLabelMetrics(swrLabelFont); + + for (int guideIndex = 0; guideIndex < geometry.swrGuides.size(); ++guideIndex) { + const CrossNeedleMeterGeometry::SwrGuide &guide = geometry.swrGuides[guideIndex]; + guideOrderCorrect = guideOrderCorrect && guide.label == expectedGuideOrder[guideIndex]; + everyLabelVisible = everyLabelVisible && !guide.displayLabel.isEmpty(); + const QPainterPath path = geometry.swrGuidePath(guide); - if (path.elementCount() != 7) { - tangentContinuous = false; + sampledPaths = sampledPaths && + path.elementCount() == geometry.swrStyle.curveSamples + 1; + if (path.isEmpty()) { + mechanicallyCalibrated = false; continue; } - const QPainterPath::Element first = path.elementAt(0); - const QPainterPath::Element visibleSecondControl = path.elementAt(2); - const QPainterPath::Element datum = path.elementAt(3); - const QPainterPath::Element hiddenFirstControl = path.elementAt(4); - const QPainterPath::Element last = path.elementAt(path.elementCount() - 1); - const double visibleYSpan = guide.registeredDatum.y() - guide.visibleUpper.y(); - const double bulge = - std::abs(guide.quadraticA) * visibleYSpan * visibleYSpan / 4.0; - maximumVisibleBulge = std::max(maximumVisibleBulge, bulge); - if (bulge > 0.01) { - ++curvedGuideCount; + // Angled-rest model: at zero power both needles park on their printed + // zeros, so every contour begins at the SAME hidden crossing just below + // the mask (the physical rest crossing the whole family fans from). + const QPointF first = path.elementAt(0); + if (guideIndex == 0) { + convergencePoint = first; } else { - ++straightGuideCount; + maximumConvergenceSpread = + std::max(maximumConvergenceSpread, + std::hypot(first.x() - convergencePoint.x(), + first.y() - convergencePoint.y())); } - const bool allCubic = - path.elementAt(1).type == QPainterPath::CurveToElement && - visibleSecondControl.type == QPainterPath::CurveToDataElement && - datum.type == QPainterPath::CurveToDataElement && - hiddenFirstControl.type == QPainterPath::CurveToElement && - path.elementAt(5).type == QPainterPath::CurveToDataElement && - last.type == QPainterPath::CurveToDataElement; - if (allCubic) { - ++cubicGuideCount; + + // First visible segment above the mask: its angle from the horizontal + // baseline characterizes how shallow the low-SWR lines emerge. Also + // count visible samples so 1.1/1.2 are confirmed to reach the face. + QPointF firstVisible; + bool haveFirstVisible = false; + for (int element = 0; element < path.elementCount(); ++element) { + const QPointF point = path.elementAt(element); + if (point.y() < maskBoundaryYAtX(geometry, point.x())) { + ++visibleSampleCount[guideIndex]; + if (!haveFirstVisible) { + firstVisible = point; + haveFirstVisible = true; + } + } + } + if (haveFirstVisible) { + const QPointF top = path.currentPosition(); + const double dx = std::abs(top.x() - firstVisible.x()); + const double dy = std::abs(top.y() - firstVisible.y()); + visibleLowerAngleDeg[guideIndex] = + std::atan2(dy, std::max(dx, 1e-9)) * 180.0 / M_PI; } - const QPointF outgoing(datum.x - visibleSecondControl.x, - datum.y - visibleSecondControl.y); - const QPointF incoming(hiddenFirstControl.x - datum.x, - hiddenFirstControl.y - datum.y); - const double crossProduct = outgoing.x() * incoming.y() - - outgoing.y() * incoming.x(); - const double tangentScale = std::hypot(outgoing.x(), outgoing.y()) * - std::hypot(incoming.x(), incoming.y()); - tangentContinuous = tangentContinuous && tangentScale > 0.0 && - std::abs(crossProduct) / tangentScale <= 1e-9 && - QPointF::dotProduct(outgoing, incoming) > 0.0; - registeredDatumsRetained = - registeredDatumsRetained && - std::hypot(datum.x - guide.registeredDatum.x(), - datum.y - guide.registeredDatum.y()) <= 1e-9; - maximumTopError = - std::max(maximumTopError, - std::hypot(first.x - guide.visibleUpper.x(), - first.y - guide.visibleUpper.y())); - minimumHiddenX = std::min(minimumHiddenX, last.x); - maximumHiddenX = std::max(maximumHiddenX, last.x); - } - report("SWR fan retains the approved mix of straight and subtly curved guides", - curvedGuideCount == 9 && straightGuideCount == 3 && - maximumVisibleBulge <= 6.01, - "curved=" + std::to_string(curvedGuideCount) + - " straight=" + std::to_string(straightGuideCount) + - " max_bulge_px=" + std::to_string(maximumVisibleBulge)); - report("SWR contours retain registered V12 datums and C1 hidden continuations", - cubicGuideCount == geometry.swrGuides.size() && tangentContinuous && - registeredDatumsRetained && maximumTopError <= 1e-9, - "cubic=" + std::to_string(cubicGuideCount)); - report("lower mask conceals distinct source-fitted guide continuations", - maximumHiddenX - minimumHiddenX > 100.0, - "hidden_x_span=" + std::to_string(maximumHiddenX - minimumHiddenX)); + + const double targetRatio = targetPowerRatio(guide); + // The contour is an exact constant-ratio locus only within the readable + // range; past full scale it is extended as face art to the common + // termination boundary (see docs D1). Verify the ratio only where both + // movements read strictly inside their calibrated maxima. + const double forwardReadMax = geometry.forwardScale.values.last() - 1e-3; + const double reflectedReadMax = geometry.reflectedScale.values.last() - 1e-3; + const auto readable = [&](const QPointF &p) { + return p.x() > 1e-6 && p.x() < forwardReadMax && p.y() < reflectedReadMax; + }; + QVector visibleCurvatures; + for (int element = 1; element < path.elementCount(); ++element) { + const QPointF point = path.elementAt(element); + const QPointF powers = geometry.powerReadingsAtIntersection(point); + if (readable(powers)) { + const double ratio = powers.y() / powers.x(); + const double relativeError = std::abs(ratio - targetRatio) / targetRatio; + maximumRatioError = std::max(maximumRatioError, relativeError); + mechanicallyCalibrated = mechanicallyCalibrated && relativeError <= 1e-6; + } + + const QPointF previous = path.elementAt(element - 1); + const QPointF midpoint = (previous + point) * 0.5; + const bool midpointVisible = + midpoint.y() < maskBoundaryYAtX(geometry, midpoint.x()); + const QPointF midpointPowers = geometry.powerReadingsAtIntersection(midpoint); + const QPointF previousPowers = geometry.powerReadingsAtIntersection(previous); + // Only verify the exact-locus approximation on segments wholly + // inside the readable range; the extended art tip is exempt. + if (midpointVisible && readable(midpointPowers) && readable(powers) && + readable(previousPowers)) { + const double midpointRatio = midpointPowers.y() / midpointPowers.x(); + maximumSegmentRatioError = + std::max(maximumSegmentRatioError, + std::abs(midpointRatio - targetRatio) / targetRatio); + const double previousVoltage = + std::sqrt(previousPowers.x() / geometry.forwardScale.values.last()); + const double currentVoltage = + std::sqrt(powers.x() / geometry.forwardScale.values.last()); + const double middleVoltage = (previousVoltage + currentVoltage) * 0.5; + const double exactForward = + geometry.forwardScale.values.last() * middleVoltage * middleVoltage; + const QPointF exactMidpoint = + geometry.needleIntersection(exactForward, exactForward * targetRatio, 1.0); + maximumPathApproximationError = + std::max(maximumPathApproximationError, + std::hypot(exactMidpoint.x() - midpoint.x(), + exactMidpoint.y() - midpoint.y())); + } + + if (midpointVisible && element >= 2) { + const QPointF before = path.elementAt(element - 2); + const QPointF firstVector = previous - before; + const QPointF secondVector = point - previous; + const double firstLength = std::hypot(firstVector.x(), firstVector.y()); + const double secondLength = std::hypot(secondVector.x(), secondVector.y()); + if (firstLength > 1e-9 && secondLength > 1e-9) { + const double cosine = std::clamp( + QPointF::dotProduct(firstVector, secondVector) / + (firstLength * secondLength), + -1.0, 1.0); + maximumTurnRadians = + std::max(maximumTurnRadians, std::acos(cosine)); + const double signedCross = firstVector.x() * secondVector.y() - + firstVector.y() * secondVector.x(); + const double signedTurn = std::atan2( + signedCross, QPointF::dotProduct(firstVector, secondVector)); + visibleCurvatures.append(signedTurn / ((firstLength + secondLength) * 0.5)); + } + } + maximumCurveBow = + std::max(maximumCurveBow, + distanceToSegment(point, first, path.currentPosition())); + } + + QVector smoothedCurvatures; + smoothedCurvatures.reserve(visibleCurvatures.size()); + for (int index = 0; index < visibleCurvatures.size(); ++index) { + double sum = 0.0; + int count = 0; + for (int offset = -2; offset <= 2; ++offset) { + const int sample = std::clamp(index + offset, 0, + static_cast(visibleCurvatures.size()) - 1); + sum += visibleCurvatures[sample]; + ++count; + } + smoothedCurvatures.append(sum / count); + } + double maximumGuideCurvature = 0.0; + for (double curvature : smoothedCurvatures) { + maximumGuideCurvature = std::max(maximumGuideCurvature, std::abs(curvature)); + } + const double curvatureThreshold = maximumGuideCurvature * 0.025; + int previousCurvatureSign = 0; + int guideCurvatureReversals = 0; + for (int index = 0; index < smoothedCurvatures.size(); ++index) { + const double curvature = smoothedCurvatures[index]; + if (index > 0) { + maximumCurvatureStep = + std::max(maximumCurvatureStep, + std::abs(curvature - smoothedCurvatures[index - 1])); + } + if (std::abs(curvature) <= curvatureThreshold) { + continue; + } + const int sign = curvature > 0.0 ? 1 : -1; + if (previousCurvatureSign != 0 && sign != previousCurvatureSign) { + ++guideCurvatureReversals; + } + previousCurvatureSign = sign; + } + totalCurvatureReversals += guideCurvatureReversals; + maximumGuideCurvatureReversals = + std::max(maximumGuideCurvatureReversals, guideCurvatureReversals); + + // Design 19: EVERY contour terminates on one common boundary a fixed + // graph_clearance short of whichever power arc is nearer. Measure the + // endpoint's gap to the nearer arc directly (geometric distance, which + // is not fooled by the past-full-scale power clamp) and require all 12 + // to sit at that clearance. This also guards swrGuidePath's fallback: + // if a contour ever failed to reach the envelope it would terminate + // elsewhere and this deviation would flag it. + const QPointF endpoint = geometry.swrGuideUpperEndpoint(guide); + const double forwardGap = geometry.forwardScale.radius - + std::hypot(endpoint.x() - geometry.forwardScale.center.x(), + endpoint.y() - geometry.forwardScale.center.y()); + const double reflectedGap = geometry.reflectedScale.radius - + std::hypot(endpoint.x() - geometry.reflectedScale.center.x(), + endpoint.y() - geometry.reflectedScale.center.y()); + const double graphGap = std::min(forwardGap, reflectedGap); + maximumEnvelopeGapError = + std::max(maximumEnvelopeGapError, + std::abs(graphGap - geometry.swrStyle.graphClearance)); + + const QPointF constructionPoint = pointOnPathAtY(path, 900.0); + orderedAboveMask = orderedAboveMask && constructionPoint.x() > previousConstructionX; + previousConstructionX = constructionPoint.x(); + + const QPointF labelCenter = + geometry.swrGuideLabelCenter(guide, swrLabelFont); + labelCenters.append(labelCenter); + const QString display = guide.displayLabel == QStringLiteral("infinity") + ? QString::fromUtf8("\xe2\x88\x9e") + : guide.displayLabel; + QRectF labelRect = + swrLabelMetrics.boundingRect(display).adjusted(-3.0, -2.0, 3.0, 2.0); + labelRect.moveCenter(labelCenter); + labelRects.append(labelRect); + const double ownDistance = distanceToPathOrUpperContinuation(labelCenter, path); + double nearestOtherDistance = std::numeric_limits::infinity(); + QString nearestLabel = guide.label; + for (int candidateIndex = 0; candidateIndex < geometry.swrGuides.size(); + ++candidateIndex) { + if (candidateIndex == guideIndex) { + continue; + } + const double candidateDistance = distanceToPathOrUpperContinuation( + labelCenter, geometry.swrGuidePath(geometry.swrGuides[candidateIndex])); + if (candidateDistance < nearestOtherDistance) { + nearestOtherDistance = candidateDistance; + if (candidateDistance < ownDistance) { + nearestLabel = geometry.swrGuides[candidateIndex].label; + } + } + } + labelsNearestOwnGuide = labelsNearestOwnGuide && nearestLabel == guide.label; + if (nearestLabel != guide.label) { + misassociatedLabels.append(guide.label + QStringLiteral("/") + nearestLabel); + } + minimumOwnGuideMargin = + std::min(minimumOwnGuideMargin, nearestOtherDistance - ownDistance); + } + bool labelBoxesSeparated = true; + QStringList overlappingLabelBoxes; + for (int first = 0; first < labelCenters.size(); ++first) { + for (int second = first + 1; second < labelCenters.size(); ++second) { + minimumLabelSpacing = + std::min(minimumLabelSpacing, + std::hypot(labelCenters[first].x() - labelCenters[second].x(), + labelCenters[first].y() - labelCenters[second].y())); + if (labelRects[first].intersects(labelRects[second])) { + labelBoxesSeparated = false; + overlappingLabelBoxes.append( + geometry.swrGuides[first].label + QLatin1Char('/') + + geometry.swrGuides[second].label); + } + } + } + + bool labelBoxesClearOtherGuides = true; + QStringList labelsCrossingOtherGuides; + bool labelBoxesClearMask = true; + QStringList labelsCrossingMask; + for (int labelIndex = 0; labelIndex < labelRects.size(); ++labelIndex) { + const QRectF &labelRect = labelRects[labelIndex]; + const bool labelClearsMask = + labelRect.bottom() + 3.0 < maskBoundaryYAtX(geometry, labelRect.left()) && + labelRect.bottom() + 3.0 < maskBoundaryYAtX(geometry, labelRect.center().x()) && + labelRect.bottom() + 3.0 < maskBoundaryYAtX(geometry, labelRect.right()); + labelBoxesClearMask = labelBoxesClearMask && labelClearsMask; + if (!labelClearsMask) { + labelsCrossingMask.append(geometry.swrGuides[labelIndex].label); + } + for (int guideIndex = 0; guideIndex < geometry.swrGuides.size(); ++guideIndex) { + if (labelIndex == guideIndex) { + continue; + } + if (geometry.swrGuidePath(geometry.swrGuides[guideIndex]) + .intersects(labelRects[labelIndex])) { + labelBoxesClearOtherGuides = false; + labelsCrossingOtherGuides.append( + geometry.swrGuides[labelIndex].label + QStringLiteral(" box/") + + geometry.swrGuides[guideIndex].label + QStringLiteral(" guide")); + } + } + } + + QFont scaleFont = QApplication::font(); + scaleFont.setPixelSize(geometry.typography.scaleNumberPixels); + scaleFont.setWeight(QFont::Medium); + const QFontMetricsF scaleMetrics(scaleFont); + bool swrLabelsClearReflectedNumbers = true; + for (int index = 0; index < geometry.reflectedScale.labels.size(); ++index) { + const QString &display = geometry.reflectedScale.labels[index]; + if (display.isEmpty()) { + continue; + } + const double angle = + CrossNeedleMeterGeometry::printedAngleForIndex(geometry.reflectedScale, index); + const QPointF radial(std::cos(angle), std::sin(angle)); + const QPointF center = pointOnScale(geometry.reflectedScale, angle) + + radial * geometry.reflectedScale.labelOffset; + QRectF reflectedRect = scaleMetrics.boundingRect(display); + reflectedRect.moveCenter(center); + for (const QRectF &swrRect : labelRects) { + swrLabelsClearReflectedNumbers = + swrLabelsClearReflectedNumbers && !swrRect.intersects(reflectedRect); + } + } + + report("SWR guide order and every printed label are retained", + guideOrderCorrect && everyLabelVisible); + report("all contours share one convergence concealed below the lower mask", + contoursShareConvergence && maximumConvergenceSpread <= 1e-6 && + convergencePoint.y() >= geometry.mask.bottomY, + "spread_px=" + std::to_string(maximumConvergenceSpread) + " convergence=(" + + std::to_string(convergencePoint.x()) + "," + + std::to_string(convergencePoint.y()) + ")"); + // Guide order: infinity, 8, 4, 3, 2.5, 2, 1.7, 1.5, 1.4, 1.3, 1.2, 1.1. + report("low-SWR contours (1.1, 1.2) reach the visible face", + visibleSampleCount[10] >= 5 && visibleSampleCount[11] >= 5, + "visible_samples_1.2=" + std::to_string(visibleSampleCount[10]) + + " visible_samples_1.1=" + std::to_string(visibleSampleCount[11])); + bool lowSwrShallowSteepening = + visibleLowerAngleDeg[11] > 0.0 && visibleLowerAngleDeg[11] < 35.0 && + visibleLowerAngleDeg[10] < 45.0; + for (int i = 11; i > 7; --i) { // 1.1 < 1.2 < 1.3 < 1.4 < 1.5 + lowSwrShallowSteepening = lowSwrShallowSteepening && + visibleLowerAngleDeg[i] > 0.0 && + visibleLowerAngleDeg[i - 1] > visibleLowerAngleDeg[i]; + } + report("low-SWR lines emerge shallow and steepen toward higher SWR", + lowSwrShallowSteepening, + "angle_1.1=" + std::to_string(visibleLowerAngleDeg[11]) + + " angle_1.2=" + std::to_string(visibleLowerAngleDeg[10]) + + " angle_1.5=" + std::to_string(visibleLowerAngleDeg[7])); + report("every sampled contour point preserves its printed power ratio", + sampledPaths && mechanicallyCalibrated && maximumRatioError <= 1e-6, + "max_relative_error=" + std::to_string(maximumRatioError)); + report("drawn line segments preserve ratio between exact samples", + maximumSegmentRatioError <= 0.001, + "max_relative_error=" + std::to_string(maximumSegmentRatioError)); + report("sampled path stays within the exact constant-ratio locus", + maximumPathApproximationError <= 0.03, + "max_error_px=" + std::to_string(maximumPathApproximationError)); + report("mechanical contours are smooth rather than traced squiggles", + maximumTurnRadians <= 0.025 && maximumCurveBow >= 1.0, + "max_turn_rad=" + std::to_string(maximumTurnRadians) + + " max_bow_px=" + std::to_string(maximumCurveBow)); + report("visible contour curvature has no tick-by-tick oscillation", + maximumGuideCurvatureReversals <= 1, + "total_reversals=" + std::to_string(totalCurvatureReversals) + + " max_per_guide=" + std::to_string(maximumGuideCurvatureReversals) + + " max_curvature_step=" + std::to_string(maximumCurvatureStep)); + report("every contour terminates on the common graph-clearance envelope", + maximumEnvelopeGapError <= 1.0, + "max_envelope_gap_error_px=" + std::to_string(maximumEnvelopeGapError)); + report("SWR contours remain ordered above the lower mask", orderedAboveMask); + report("every SWR number is associated with its own contour", + labelsNearestOwnGuide && minimumOwnGuideMargin >= 1.0, + "minimum_margin_px=" + std::to_string(minimumOwnGuideMargin) + + " mismatches=" + misassociatedLabels.join(QStringLiteral(", ")).toStdString()); + report("SWR number anchors retain readable separation", + minimumLabelSpacing >= 38.0, + "minimum_spacing_px=" + std::to_string(minimumLabelSpacing)); + report("SWR number boxes do not overlap one another", labelBoxesSeparated, + overlappingLabelBoxes.join(QStringLiteral(", ")).toStdString()); + report("SWR number backgrounds cannot erase a neighboring contour", + labelBoxesClearOtherGuides, + labelsCrossingOtherGuides.join(QStringLiteral(", ")).toStdString()); + report("SWR number boxes remain above the lower mask", labelBoxesClearMask, + labelsCrossingMask.join(QStringLiteral(", ")).toStdString()); + report("SWR numbers remain clear of Reflected-scale numbers", + swrLabelsClearReflectedNumbers); report("SWR contour ink survives applet-size downsampling", geometry.swrStyle.guideWidth >= 3.0); + QFont wideSwrLabelFont = swrLabelFont; + wideSwrLabelFont.setStretch(175); + const QFontMetricsF wideSwrLabelMetrics(wideSwrLabelFont); + QVector wideLabelRects; + bool fontChangeRepositionedLabels = false; + bool wideLabelBoxesSeparated = true; + bool wideLabelBoxesClearMask = true; + for (int index = 0; index < geometry.swrGuides.size(); ++index) { + const CrossNeedleMeterGeometry::SwrGuide &guide = geometry.swrGuides[index]; + const QPointF center = + geometry.swrGuideLabelCenter(guide, wideSwrLabelFont); + fontChangeRepositionedLabels = + fontChangeRepositionedLabels || + std::hypot(center.x() - labelCenters[index].x(), + center.y() - labelCenters[index].y()) > 0.01; + const QString display = guide.displayLabel == QStringLiteral("infinity") + ? QString::fromUtf8("\xe2\x88\x9e") + : guide.displayLabel; + QRectF box = + wideSwrLabelMetrics.boundingRect(display).adjusted(-3.0, -2.0, 3.0, 2.0); + box.moveCenter(center); + wideLabelBoxesClearMask = + wideLabelBoxesClearMask && + box.bottom() + 3.0 < maskBoundaryYAtX(geometry, box.left()) && + box.bottom() + 3.0 < maskBoundaryYAtX(geometry, box.center().x()) && + box.bottom() + 3.0 < maskBoundaryYAtX(geometry, box.right()); + wideLabelRects.append(box); + } + for (int first = 0; first < wideLabelRects.size(); ++first) { + for (int second = first + 1; second < wideLabelRects.size(); ++second) { + wideLabelBoxesSeparated = + wideLabelBoxesSeparated && + !wideLabelRects[first].intersects(wideLabelRects[second]); + } + } + report("SWR label placement cache is keyed by the rendered font", + fontChangeRepositionedLabels); + report("wider rendered-font SWR labels remain collision-free", + wideLabelBoxesSeparated && wideLabelBoxesClearMask); + bool maskSymmetric = true; for (int i = 0; i < geometry.mask.boundary.size(); ++i) { const QPointF left = geometry.mask.boundary[i]; @@ -358,6 +957,22 @@ void testMeterMathAndActiveProof() { report("amplifier output selects x100 range", CrossNeedleMeterGeometry::rangeMultiplierFor(100, true) == 100.0); + const QPointF documentedIntersection = geometry.needleIntersection(10.0, 0.4, 1.0); + const QPointF multipliedIntersection = geometry.needleIntersection(100.0, 4.0, 10.0); + const double documentedRangeError = + std::hypot(documentedIntersection.x() - multipliedIntersection.x(), + documentedIntersection.y() - multipliedIntersection.y()); + double documentedGuideDistance = 0.0; + const QString documentedGuide = + geometry.nearestGuideLabel(documentedIntersection, &documentedGuideDistance); + report("10 W / 0.4 W and 100 W / 4 W produce the same needle intersection", + documentedRangeError <= 1e-6, + "error_px=" + std::to_string(documentedRangeError)); + report("documented 10 W / 0.4 W calibration lands on SWR 1.5", + documentedGuide == QStringLiteral("1.5") && documentedGuideDistance <= 0.1, + "guide=" + documentedGuide.toStdString() + + " error_px=" + std::to_string(documentedGuideDistance)); + const QPointF intersection = geometry.needleIntersection( geometry.validation.activeForwardWatts, geometry.validation.activeReflectedWatts, geometry.validation.rangeMultiplier); @@ -365,7 +980,9 @@ void testMeterMathAndActiveProof() { std::hypot(intersection.x() - geometry.validation.intersection.x(), intersection.y() - geometry.validation.intersection.y()); report("active needle intersection matches approved proof", intersectionError < 0.01, - "error_px=" + std::to_string(intersectionError)); + "actual=(" + std::to_string(intersection.x()) + "," + + std::to_string(intersection.y()) + ") error_px=" + + std::to_string(intersectionError)); double guideDistance = 0.0; const QString nearestGuide = geometry.nearestGuideLabel(intersection, &guideDistance); @@ -373,18 +990,90 @@ void testMeterMathAndActiveProof() { nearestGuide == geometry.validation.guide && guideDistance <= geometry.validation.maximumGuideError, "guide=" + nearestGuide.toStdString() + " error_px=" + std::to_string(guideDistance)); - report("active proof retains the approved source-fitted guide error", - std::abs(guideDistance - 1.0640493478502941) <= 0.05, + report("active proof lies on the generated constant-ratio contour", + guideDistance <= 0.1, "error_px=" + std::to_string(guideDistance)); - const CrossNeedleMeterGeometry::SwrGuide *activeGuide = - findGuide(geometry, geometry.validation.guide); - report("1.5 guide retains approved datum, curvature, and label anchor", - activeGuide && near(activeGuide->registeredDatum.x(), 957.5, 1e-9) && - near(activeGuide->registeredDatum.y(), 880.0, 1e-9) && - near(activeGuide->quadraticA, -0.000102863228292, 1e-15) && - near(geometry.swrGuideLabelCenter(*activeGuide).x(), 1095.0, 1e-9) && - near(geometry.swrGuideLabelCenter(*activeGuide).y(), 690.0, 1e-9)); + bool everyGuideSelectsItself = true; + bool everyGuideReconstructsSWR = true; + bool rangeInvariant = true; + double maximumGuideDistance = 0.0; + double maximumSWRerror = 0.0; + double maximumRangeError = 0.0; + constexpr double kFractions[] = {0.45, 0.70, 0.90}; + for (const CrossNeedleMeterGeometry::SwrGuide &guide : geometry.swrGuides) { + const double ratio = targetPowerRatio(guide); + // Sample within the READABLE range (both movements <= full scale) AND + // within the drawn contour (crossing still inside the termination + // envelope) — a contour may hit the envelope before full scale. + const double readableForward = + std::min(geometry.forwardScale.values.last(), + geometry.reflectedScale.values.last() / ratio); + const double clearance = geometry.swrStyle.graphClearance; + const auto insideEnvelope = [&](double f) { + const QPointF p = geometry.needleIntersection(f, f * ratio, 1.0); + return std::hypot(p.x() - geometry.forwardScale.center.x(), + p.y() - geometry.forwardScale.center.y()) < + geometry.forwardScale.radius - clearance && + std::hypot(p.x() - geometry.reflectedScale.center.x(), + p.y() - geometry.reflectedScale.center.y()) < + geometry.reflectedScale.radius - clearance; + }; + double contourForward = readableForward; + if (!insideEnvelope(readableForward)) { + double lower = 0.0; + double upper = readableForward; + for (int k = 0; k < 48; ++k) { + const double middle = (lower + upper) * 0.5; + if (insideEnvelope(middle)) { + lower = middle; + } else { + upper = middle; + } + } + contourForward = lower; + } + for (const double fraction : kFractions) { + const double forwardPower = contourForward * fraction; + const double reflectedPower = forwardPower * ratio; + const QPointF point = + geometry.needleIntersection(forwardPower, reflectedPower, 1.0); + double distance = 0.0; + const QString selectedGuide = geometry.nearestGuideLabel(point, &distance); + everyGuideSelectsItself = everyGuideSelectsItself && + selectedGuide == guide.label && distance <= 0.1; + maximumGuideDistance = std::max(maximumGuideDistance, distance); + + const double reconstructed = + CrossNeedleMeterGeometry::swrFromPowers(forwardPower, reflectedPower); + if (std::isinf(guide.swr)) { + everyGuideReconstructsSWR = everyGuideReconstructsSWR && + std::isinf(reconstructed); + } else { + const double error = std::abs(reconstructed - guide.swr); + maximumSWRerror = std::max(maximumSWRerror, error); + everyGuideReconstructsSWR = everyGuideReconstructsSWR && error <= 1e-9; + } + + for (const double multiplier : {10.0, 100.0}) { + const QPointF multiplied = geometry.needleIntersection( + forwardPower * multiplier, reflectedPower * multiplier, multiplier); + const double error = + std::hypot(multiplied.x() - point.x(), multiplied.y() - point.y()); + maximumRangeError = std::max(maximumRangeError, error); + rangeInvariant = rangeInvariant && error <= 1e-6; + } + } + } + report("every printed SWR guide selects itself at multiple powers", + everyGuideSelectsItself, + "maximum_distance_px=" + std::to_string(maximumGuideDistance)); + report("every printed guide reconstructs its declared SWR", + everyGuideReconstructsSWR, + "maximum_swr_error=" + std::to_string(maximumSWRerror)); + report("SWR contours are invariant across x1, x10, and x100 ranges", + rangeInvariant, + "maximum_position_error_px=" + std::to_string(maximumRangeError)); } void testWidgetStateAndRender() { @@ -398,6 +1087,30 @@ void testWidgetStateAndRender() { report("RX starts with both movements parked", !meter.isTransmitting() && meter.forwardWatts() == 0.0 && meter.reflectedWatts() == 0.0 && meter.swr() == 1.0); + report("Range legend is visible by default", meter.rangeLegendVisible()); + const QImage rangeVisible = renderWidget(meter); + const double multiplierBeforeHide = meter.rangeMultiplier(); + const double forwardAngleBeforeHide = meter.geometry().forwardAngle(0.0, 1.0); + const double reflectedAngleBeforeHide = meter.geometry().reflectedAngle(0.0, 1.0); + meter.setRangeLegendVisible(false); + const QImage rangeHidden = renderWidget(meter); + const QRect rangeLegendRegion(480, 16, 90, 70); + const QRect unaffectedControlRegion(20, 180, 90, 100); + report("Show Range hides the printed legend", + !meter.rangeLegendVisible() && + differingPixels(rangeVisible, rangeHidden, rangeLegendRegion) > 100); + report("hiding Range does not change unrelated face ink", + differingPixels(rangeVisible, rangeHidden, unaffectedControlRegion) == 0); + report("hiding Range does not change scale calibration", + near(meter.rangeMultiplier(), multiplierBeforeHide, 1e-12) && + near(meter.geometry().forwardAngle(0.0, 1.0), + forwardAngleBeforeHide, 1e-12) && + near(meter.geometry().reflectedAngle(0.0, 1.0), + reflectedAngleBeforeHide, 1e-12)); + meter.setRangeLegendVisible(true); + const QImage rangeRestored = renderWidget(meter); + report("re-enabling Range restores the deterministic face exactly", + differingPixels(rangeVisible, rangeRestored) == 0); const QImage swrInkBaseline = renderWidget(meter); meter.setPowerScale(200, false); @@ -675,6 +1388,11 @@ void testWidgetStateAndRender() { } // namespace int main(int argc, char **argv) { + TestSettingsProfile settingsProfile(QStringLiteral("aether-cross-needle-meter-test")); + if (!settingsProfile.isValid()) { + std::fprintf(stderr, "[FAIL] could not create temporary settings profile\n"); + return 1; + } qputenv("AETHER_AUTOMATION", "1"); QApplication application(argc, argv); std::printf("Cross-needle meter construction test harness\n\n"); diff --git a/tests/cwx_panel_test.cpp b/tests/cwx_panel_test.cpp index 8d65840b3..1dcc34740 100644 --- a/tests/cwx_panel_test.cpp +++ b/tests/cwx_panel_test.cpp @@ -1,6 +1,8 @@ // Focused CWX panel behavior tests. // Run: ./build/cwx_panel_test +#include "TestSettingsProfile.h" +#include "core/AppSettings.h" #include "gui/CwxPanel.h" #include "models/CwxModel.h" @@ -228,7 +230,12 @@ void testResendPreservesSpeedModifiers() int main(int argc, char** argv) { + TestSettingsProfile settingsProfile(QStringLiteral("aether-cwx-panel-test")); + if (!settingsProfile.isValid()) { + return 1; + } QApplication app(argc, argv); + AppSettings::instance().load(); std::printf("CWX panel behavior test harness\n\n"); testLiveButtonTogglesOff(); diff --git a/tests/flex_control_dialog_size_test.cpp b/tests/flex_control_dialog_size_test.cpp index d680a1928..e8a8501d1 100644 --- a/tests/flex_control_dialog_size_test.cpp +++ b/tests/flex_control_dialog_size_test.cpp @@ -15,6 +15,7 @@ // // Build: CMake target `flex_control_dialog_size_test`. Exit 0 = pass. +#include "TestSettingsProfile.h" #include "gui/FlexControlDialog.h" #include @@ -59,7 +60,12 @@ QPushButton* findCompactButton(FlexControlDialog& dialog) int main(int argc, char** argv) { + TestSettingsProfile settingsProfile(QStringLiteral("aether-flex-control-dialog-test")); + if (!settingsProfile.isValid()) { + return 1; + } QApplication app(argc, argv); + AppSettings::instance().load(); std::printf("FlexControlDialog screen-fit test harness (#3662)\n\n"); // Start from the non-compact layout so the auto-engage path is exercised. diff --git a/tests/help_dialog_test.cpp b/tests/help_dialog_test.cpp index be94982f6..bfd141c46 100644 --- a/tests/help_dialog_test.cpp +++ b/tests/help_dialog_test.cpp @@ -1,6 +1,8 @@ // Standalone test harness for HelpDialog guide search. // Build: CMake target `help_dialog_test`. Exit 0 = pass. +#include "TestSettingsProfile.h" +#include "core/AppSettings.h" #include "gui/HelpDialog.h" #include @@ -179,7 +181,12 @@ void testEmptyQueryDisablesFind() int main(int argc, char** argv) { + TestSettingsProfile settingsProfile(QStringLiteral("aether-help-dialog-test")); + if (!settingsProfile.isValid()) { + return 1; + } QApplication app(argc, argv); + AppSettings::instance().load(); std::printf("HelpDialog find test harness\n\n"); testFindNextAndWrap(); diff --git a/tests/kiwi_sdr_manager_password_test.cpp b/tests/kiwi_sdr_manager_password_test.cpp index f599a570d..13c7ac7fb 100644 --- a/tests/kiwi_sdr_manager_password_test.cpp +++ b/tests/kiwi_sdr_manager_password_test.cpp @@ -1,3 +1,4 @@ +#include "TestSettingsProfile.h" #include "core/AppSettings.h" #include "core/KiwiSdrManager.h" @@ -87,23 +88,11 @@ void clearProfiles() int main(int argc, char** argv) { - QTemporaryDir settingsHome; - if (!settingsHome.isValid()) { + TestSettingsProfile settingsProfile(QStringLiteral("aether-kiwi-password-test")); + if (!settingsProfile.isValid()) { return fail("could not create isolated settings home"); } - qputenv("CFFIXED_USER_HOME", settingsHome.path().toUtf8()); - qputenv("XDG_CONFIG_HOME", settingsHome.path().toUtf8()); QCoreApplication app(argc, argv); - const QString configDir = - QStandardPaths::writableLocation(QStandardPaths::GenericConfigLocation) - + "/AetherSDR"; - QDir().mkpath(configDir); - QFile settingsFile(configDir + "/AetherSDR.settings"); - if (!settingsFile.open(QIODevice::WriteOnly | QIODevice::Text) - || settingsFile.write("\n") < 0) { - return fail("could not initialize isolated AppSettings file"); - } - settingsFile.close(); AppSettings::instance().load(); clearProfiles(); diff --git a/tests/meter_model_test.cpp b/tests/meter_model_test.cpp index 07f8907ce..753d9ed61 100644 --- a/tests/meter_model_test.cpp +++ b/tests/meter_model_test.cpp @@ -256,6 +256,26 @@ void testDirectionalPowerUsesDirectReflectedMeter() && model.reflectedPowerUpdatedAtMs() == 0); } +void testNativeSwrRemainsRadioProvidedAtLowPower() +{ + MeterModel model; + model.defineMeter(txMeter(8, "FWDPWR", "dBm")); + model.defineMeter(txMeter(10, "SWR", "SWR")); + + float emittedSwr = 0.0f; + QObject::connect(&model, &MeterModel::txMetersChanged, + [&emittedSwr](float, float swr) { + emittedSwr = swr; + }); + + model.updateValues({8, 10}, {rawDb(6.1f), rawDb(1.0859375f)}); + + report("MeterModel preserves the radio-native SWR sample at low power", + nearlyEqual(model.fwdPowerInstant(), 0.004f) + && nearlyEqual(model.swr(), 1.0859375f) + && nearlyEqual(emittedSwr, 1.0859375f)); +} + } // namespace int main(int argc, char** argv) @@ -272,6 +292,7 @@ int main(int argc, char** argv) testRemovingCompPeakMarksCompressionUnavailable(); testRemovingAdjacentMetersDoesNotClearCompPeak(); testDirectionalPowerUsesDirectReflectedMeter(); + testNativeSwrRemainsRadioProvidedAtLowPower(); return g_failed == 0 ? 0 : 1; } diff --git a/tests/pan_layout_dialog_size_test.cpp b/tests/pan_layout_dialog_size_test.cpp index 3f9d9c713..0fb12087f 100644 --- a/tests/pan_layout_dialog_size_test.cpp +++ b/tests/pan_layout_dialog_size_test.cpp @@ -12,6 +12,8 @@ // // Build: CMake target `pan_layout_dialog_size_test`. Exit 0 = pass. +#include "TestSettingsProfile.h" +#include "core/AppSettings.h" #include "gui/PanLayoutDialog.h" #include @@ -45,7 +47,12 @@ int topInRoot(QWidget* w, QWidget* root) int main(int argc, char** argv) { + TestSettingsProfile settingsProfile(QStringLiteral("aether-pan-layout-dialog-test")); + if (!settingsProfile.isValid()) { + return 1; + } QApplication app(argc, argv); + AppSettings::instance().load(); std::printf("PanLayoutDialog Cancel-button overlap test harness\n\n"); // maxPans = 8 enables every layout (the tallest grid — worst case). diff --git a/tests/passive_spots_policy_test.cpp b/tests/passive_spots_policy_test.cpp index ae6fb5677..5d779cd0a 100644 --- a/tests/passive_spots_policy_test.cpp +++ b/tests/passive_spots_policy_test.cpp @@ -1,3 +1,4 @@ +#include "TestSettingsProfile.h" #include "core/AppSettings.h" #include "core/SpotCommandPolicy.h" @@ -50,7 +51,12 @@ void testSendPolicyUsesAppSettings() int main(int argc, char** argv) { + TestSettingsProfile settingsProfile(QStringLiteral("aether-passive-spots-policy-test")); + if (!settingsProfile.isValid()) { + return 1; + } QCoreApplication app(argc, argv); + AppSettings::instance().load(); testSettingParsing(); testSendPolicyUsesAppSettings(); diff --git a/tests/pms_mailbox_test.cpp b/tests/pms_mailbox_test.cpp index 81bc3920d..debe9a0ff 100644 --- a/tests/pms_mailbox_test.cpp +++ b/tests/pms_mailbox_test.cpp @@ -2,6 +2,8 @@ // Personal Mailbox System (PMS). These exercise the protocol layer in isolation // (no DSP / radio), driving the mailbox exactly as a remote caller's TNC would. +#include "TestSettingsProfile.h" +#include "core/AppSettings.h" #include "core/pms/PmsMailbox.h" #include "core/tnc/Ax25.h" #include "core/tnc/Ax25Connection.h" @@ -359,14 +361,17 @@ int main(int argc, char** argv) // repeatable and never touches a live operator's mailbox. AppSettings derives // its path from the home/config location, so redirect those before the // singleton is first used. - const QString tmpHome = QDir::tempPath() + QStringLiteral("/aether_pms_test_home"); - QDir(tmpHome).removeRecursively(); - QDir().mkpath(tmpHome); + TestSettingsProfile settingsProfile(QStringLiteral("aether-pms-mailbox-test")); + if (!settingsProfile.isValid()) { + return 1; + } // PmsMailbox honours AETHER_PMS_DIR for its JSON store; point it at the clean // temp dir so the test is repeatable and never touches a real mailbox. - qputenv("AETHER_PMS_DIR", (tmpHome + QStringLiteral("/pms")).toUtf8()); + qputenv("AETHER_PMS_DIR", + (settingsProfile.path() + QStringLiteral("/pms")).toUtf8()); QCoreApplication app(argc, argv); + AppSettings::instance().load(); testAddress(); testFrameRoundTrip(); testConnection(); diff --git a/tests/qso_recorder_slice_lifetime_test.cpp b/tests/qso_recorder_slice_lifetime_test.cpp index cbbf46da5..0ad6c8f8a 100644 --- a/tests/qso_recorder_slice_lifetime_test.cpp +++ b/tests/qso_recorder_slice_lifetime_test.cpp @@ -10,6 +10,8 @@ // (freq 0 / empty mode), so the built filename drops the freq/mode components. // That is deterministic with the fix and impossible to satisfy with the bug. +#include "TestSettingsProfile.h" +#include "core/AppSettings.h" #include "core/QsoRecorder.h" #include "models/SliceModel.h" @@ -54,7 +56,12 @@ static void configure(QsoRecorder& rec, const QString& dir) int main(int argc, char** argv) { + TestSettingsProfile settingsProfile(QStringLiteral("aether-qso-recorder-test")); + if (!settingsProfile.isValid()) { + return 1; + } QCoreApplication app(argc, argv); + AppSettings::instance().load(); QTemporaryDir tmp; EXPECT_TRUE(tmp.isValid()); diff --git a/tests/s_meter_geometry_test.cpp b/tests/s_meter_geometry_test.cpp new file mode 100644 index 000000000..8721438de --- /dev/null +++ b/tests/s_meter_geometry_test.cpp @@ -0,0 +1,1134 @@ +#include "TestSettingsProfile.h" +#include "core/AppSettings.h" +#include "gui/AnalogMeterFaceTheme.h" +#include "gui/RadioSwrValidityFilter.h" +#include "gui/SMeterGeometry.h" +#include "gui/SMeterWidget.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace { + +int g_failures = 0; + +struct AccessibleValueUpdate { + QObject* object{nullptr}; + QString value; +}; + +QVector* g_accessibleValueUpdates = nullptr; + +void captureAccessibleValueUpdate(QAccessibleEvent* event) +{ + if (!g_accessibleValueUpdates || event->type() != QAccessible::ValueChanged) { + return; + } + + const QAccessibleValueChangeEvent* valueEvent = + static_cast(event); + g_accessibleValueUpdates->push_back({event->object(), valueEvent->value().toString()}); +} + +void expect(bool condition, const char* message) +{ + if (!condition) { + std::cerr << "FAIL: " << message << '\n'; + ++g_failures; + } +} + +void expect(bool condition, const QString& message) +{ + if (!condition) { + std::cerr << "FAIL: " << message.toStdString() << '\n'; + ++g_failures; + } +} + +bool near(double actual, double expected, double tolerance = 1e-9) +{ + return std::abs(actual - expected) <= tolerance; +} + +void waitForEvents(int milliseconds) +{ + QEventLoop loop; + QTimer::singleShot(milliseconds, &loop, &QEventLoop::quit); + loop.exec(QEventLoop::ExcludeUserInputEvents); +} + +void expectSingleAccessibleValue( + const QVector& updates, + const AetherSDR::SMeterWidget& meter, + const QStringList& expectedFragments, + const QString& context) +{ + expect(updates.size() == 1, + context + QStringLiteral(" emits exactly one settled value update")); + if (updates.size() != 1) { + return; + } + + expect(updates.constFirst().object == &meter, + context + QStringLiteral(" targets the S-meter widget")); + for (const QString& fragment : expectedFragments) { + expect(updates.constFirst().value.contains(fragment), + context + QStringLiteral(" contains '%1'").arg(fragment)); + } +} + +void testAccessibilityAnnouncements() +{ + QVector updates; + g_accessibleValueUpdates = &updates; + const QAccessible::UpdateHandler previousHandler = + QAccessible::installUpdateHandler(captureAccessibleValueUpdate); + const bool wasAccessible = QAccessible::isActive(); + QAccessible::setActive(true); + + AetherSDR::SMeterWidget meter; + meter.show(); + meter.activateWindow(); + meter.setFocus(Qt::OtherFocusReason); + QApplication::processEvents(); + expect(meter.hasFocus(), "accessibility announcement fixture receives focus"); + + updates.clear(); + meter.setLevel(-110.0f); + meter.setLevel(-90.0f); + meter.setLevel(-61.0f); + waitForEvents(45); + expect(updates.isEmpty(), + "RX burst waits for the throttled accessibility interval"); + waitForEvents(90); + expectSingleAccessibleValue( + updates, meter, + {QStringLiteral("S9+12"), QStringLiteral("-61 dBm")}, + QStringLiteral("RX burst")); + + updates.clear(); + meter.setLevel(-61.0f); + waitForEvents(135); + expect(updates.isEmpty(), + "unchanged RX display value is not announced repeatedly"); + + updates.clear(); + meter.setTransmitting(true); + meter.setTxMode(QStringLiteral("Power")); + meter.setTxMeters(15.0f, 1.2f); + meter.setTxMeters(65.0f, 1.7f); + waitForEvents(135); + expectSingleAccessibleValue( + updates, meter, + {QStringLiteral("Transmit power"), QStringLiteral("65 watts")}, + QStringLiteral("TX power burst and transmit transition")); + + updates.clear(); + meter.setTxMode(QStringLiteral("SWR")); + meter.setTxMeters(65.0f, 1.7f); + waitForEvents(135); + expectSingleAccessibleValue( + updates, meter, + {QStringLiteral("Transmit SWR"), QStringLiteral("1.7")}, + QStringLiteral("TX SWR mode change")); + + updates.clear(); + meter.setTxMode(QStringLiteral("Level")); + meter.setMicMeters(-12.0f, 0.0f, -10.0f, 4.0f); + waitForEvents(135); + expectSingleAccessibleValue( + updates, meter, + {QStringLiteral("Transmit level"), QStringLiteral("-12 dB")}, + QStringLiteral("TX level mode change and meter burst")); + + updates.clear(); + meter.setTxMode(QStringLiteral("Compression")); + meter.setMicMeters(-12.0f, 0.0f, -10.0f, 4.0f); + waitForEvents(135); + expectSingleAccessibleValue( + updates, meter, + {QStringLiteral("Transmit compression"), QStringLiteral("-4 dB")}, + QStringLiteral("TX compression mode change and meter burst")); + + updates.clear(); + meter.setTransmitting(false); + waitForEvents(135); + expectSingleAccessibleValue( + updates, meter, + {QStringLiteral("S9+12"), QStringLiteral("-61 dBm")}, + QStringLiteral("return to receive transition")); + + meter.close(); + QAccessible::installUpdateHandler(previousHandler); + QAccessible::setActive(wasAccessible); + g_accessibleValueUpdates = nullptr; +} + +void testRadioSwrValidityFilter() +{ + AetherSDR::SMeterWidget meter; + meter.setTxMode(QStringLiteral("SWR")); + meter.setTransmitting(true); + + meter.setRadioTxMeters(12.0f, 12.0f, 2.8f); + expect(meter.accessibleValueText().contains(QStringLiteral("2.8")), + "powered native radio SWR is displayed directly"); + expect(meter.property("txSwrSource").toString() == QStringLiteral("radio"), + "standard S-meter identifies native radio SWR as its source"); + expect(!meter.property("txSwrHeld").toBool(), + "powered native radio SWR is not marked held"); + expect(near(meter.property("txSwrMinimumForwardWatts").toDouble(), 2.4, 1e-5), + "native SWR validity follows the measured TX envelope peak"); + + meter.setRadioTxMeters(12.0f, 12.0f, 1.0859375f); + expect(meter.accessibleValueText().contains(QStringLiteral("2.8")), + "an early near-unity packet waits for forward-power confirmation"); + expect(meter.property("txSwrHeld").toBool(), + "an early near-unity packet is identified as held"); + + meter.setRadioTxMeters(10.0f, 0.004f, 1.0859375f); + expect(meter.accessibleValueText().contains(QStringLiteral("2.8")), + "low-forward-power radio SWR does not replace the last valid ratio"); + expect(meter.property("txSwrHeld").toBool(), + "low-forward-power radio SWR is identified as held"); + + meter.setRadioTxMeters(10.0f, 1.0f, 5.0f); + expect(meter.accessibleValueText().contains(QStringLiteral("5.0")), + "a worsening native SWR warning survives low forward-power foldback"); + + meter.setRadioTxMeters(10.0f, 10.0f, 1.0f); + meter.setRadioTxMeters(10.0f, 10.0f, 1.0f); + expect(meter.accessibleValueText().contains(QStringLiteral("5.0")), + "two powered unity packets do not reproduce the tune-reset artifact"); + meter.setRadioTxMeters(10.0f, 10.0f, 1.0f); + expect(meter.accessibleValueText().contains(QStringLiteral("1.0")), + "sustained powered unity SWR is accepted after confirmation"); + + meter.setRadioTxMeters(10.0f, 10.0f, 2.6f); + meter.setRadioTxMeters(10.0f, 10.0f, -25.0f); + expect(meter.accessibleValueText().contains(QStringLiteral("2.6")), + "one invalid below-unity radio sample cannot flash the upper stop"); + expect(meter.property("txSwrHeld").toBool(), + "a transient below-unity radio sample is identified as held"); + expect(near(meter.property("txSwrRaw").toDouble(), -25.0), + "diagnostics preserve the raw below-unity radio sample"); + + meter.setTransmitting(false); + meter.setTransmitting(true); + expect(meter.accessibleValueText().contains(QStringLiteral("1.0")), + "un-key resets the held native radio SWR state"); + expect(!meter.property("txSwrHeld").toBool(), + "un-key clears the native radio SWR hold marker"); +} + +void testRadioSwrValidityFilterAdaptsToSustainedLowerPower() +{ + AetherSDR::RadioSwrValidityFilter filter; + + AetherSDR::RadioSwrValidityFilter::Result result = + filter.update(100.0f, 3.0f, 0, 3.0f); + expect(near(result.displayedSwr, 3.0) + && near(result.forwardEnvelopeWatts, 100.0) + && near(result.minimumForwardWatts, 20.0), + "native SWR filter attacks immediately to a powered envelope peak"); + + result = filter.update(10.0f, 1.0f, 200, 3.0f); + expect(result.held && near(result.displayedSwr, 3.0) + && result.minimumForwardWatts > 10.0f, + "brief lower-power near-unity sample remains held"); + + result = filter.update(0.0f, 5.0f, 250, 3.0f); + expect(result.held && near(result.displayedSwr, 3.0), + "zero-power high-SWR noise cannot raise the held reading"); + + result = filter.update(10.0f, 1.5f, 1000, 3.0f); + expect(!result.held && near(result.displayedSwr, 1.5) + && result.minimumForwardWatts < 10.0f, + "sustained lower-power operating point becomes authoritative"); + + filter.reset(); + filter.update(100.0f, 5.0f, 0, 3.0f); + result = filter.update(10.0f, 2.0f, 100, 3.0f); + expect(result.held && near(result.displayedSwr, 5.0) + && result.minimumForwardWatts > 10.0f, + "brief measurable-power SWR recovery remains held"); + result = filter.update(10.0f, 2.1f, 300, 3.0f); + expect(result.held && near(result.displayedSwr, 5.0), + "measurable-power recovery remains held before its time window"); + result = filter.update(10.0f, 2.0f, 350, 3.0f); + expect(!result.held && near(result.displayedSwr, 2.0) + && result.minimumForwardWatts > 10.0f, + "confirmed SWR recovery replaces a stale warning before envelope release"); + + result = filter.update(0.0017f, 1.125f, 700, 3.0f); + expect(result.held && near(result.displayedSwr, 2.0), + "real-radio no-carrier SWR artifact remains rejected"); + result = filter.update(0.0017f, 1.125f, 2000, 3.0f); + expect(result.held && near(result.displayedSwr, 2.0), + "no-carrier packets cannot satisfy a timed recovery window"); + + filter.reset(); + filter.update(100.0f, 5.0f, 0, 3.0f); + result = filter.update(0.1f, 1.1f, 100, 3.0f); + expect(result.held && near(result.displayedSwr, 5.0) + && result.minimumForwardWatts > 0.1f, + "brief near-unity recovery remains held in the low-power twilight zone"); + result = filter.update(0.1f, 1.15f, 300, 3.0f); + expect(result.held && near(result.displayedSwr, 5.0) + && result.minimumForwardWatts > 0.1f, + "near-unity recovery remains held before its wall-clock confirmation"); + result = filter.update(0.1f, 1.1f, 350, 3.0f); + expect(!result.held && near(result.displayedSwr, 1.1, 1e-5) + && result.minimumForwardWatts > 0.1f, + "confirmed near-unity recovery replaces stale SWR before envelope release"); + + for (const float boundarySwr : {1.0f, 1.2f}) { + filter.reset(); + filter.update(100.0f, 5.0f, 0, 3.0f); + result = filter.update(0.1f, boundarySwr, 100, 3.0f); + expect(result.held && near(result.displayedSwr, 5.0), + QStringLiteral("low-power SWR %1 starts bounded confirmation") + .arg(boundarySwr)); + result = filter.update(0.1f, boundarySwr, 350, 3.0f); + expect(!result.held + && near(result.displayedSwr, boundarySwr, 1e-5), + QStringLiteral("low-power SWR %1 is accepted at the confirmation boundary") + .arg(boundarySwr)); + } + + filter.reset(); + filter.update(100.0f, 5.0f, 0, 3.0f); + result = filter.update(0.1f, 1.1f, 100, 3.0f); + expect(result.held && near(result.displayedSwr, 5.0), + "near-unity recovery starts a confirmation window"); + result = filter.update(0.0017f, 1.1f, 300, 3.0f); + expect(result.held && near(result.displayedSwr, 5.0), + "loss of measurable carrier interrupts near-unity confirmation"); + result = filter.update(0.1f, 1.1f, 400, 3.0f); + expect(result.held && near(result.displayedSwr, 5.0), + "powered near-unity recovery restarts after an interruption"); + result = filter.update(0.1f, 1.1f, 649, 3.0f); + expect(result.held && near(result.displayedSwr, 5.0), + "restarted recovery cannot reuse elapsed time from before the interruption"); + result = filter.update(0.1f, 1.1f, 650, 3.0f); + expect(!result.held && near(result.displayedSwr, 1.1, 1e-5), + "restarted recovery becomes authoritative after a fresh confirmation window"); + + filter.reset(); + filter.update(100.0f, 2.6f, 0, 3.0f); + result = filter.update(10.0f, -25.0f, 100, 3.0f); + expect(result.held && near(result.displayedSwr, 2.6, 1e-5), + QStringLiteral("one below-unity sentinel cannot peg the SWR display " + "(held=%1 displayed=%2)") + .arg(result.held) + .arg(result.displayedSwr)); + result = filter.update(10.0f, -25.0f, 300, 3.0f); + expect(result.held && near(result.displayedSwr, 2.6, 1e-5), + QStringLiteral("below-unity sentinel remains held before its time window " + "(held=%1 displayed=%2)") + .arg(result.held) + .arg(result.displayedSwr)); + result = filter.update(10.0f, -25.0f, 350, 3.0f); + expect(!result.held && near(result.displayedSwr, 3.0), + "persistent measurable-power sentinel reaches the upper stop during foldback"); + + filter.reset(); + filter.update(100.0f, 2.0f, 0, 3.0f); + result = filter.update(10.0f, 5.0f, 100, 3.0f); + expect(!result.held && near(result.displayedSwr, 5.0), + "powered foldback still surfaces a worsening native warning"); + + result = filter.update(10.0f, 1.0f, 1000, 3.0f); + expect(result.held && near(result.displayedSwr, 5.0), + "first powered near-unity sample remains held after envelope release"); + result = filter.update(10.0f, 1.0f, 1010, 3.0f); + expect(result.held && near(result.displayedSwr, 5.0), + "second powered near-unity sample remains held"); + result = filter.update(10.0f, 1.0f, 1020, 3.0f); + expect(!result.held && near(result.displayedSwr, 1.0), + "third powered near-unity sample is accepted"); + + filter.reset(); + result = filter.update(0.0f, 5.0f, 0, 3.0f); + expect(!result.hasReading && !result.held && near(result.displayedSwr, 1.0), + "reset filter ignores an unpowered first SWR sample"); +} + +bool sameTicks(const QVector& first, + const QVector& second) +{ + if (first.size() != second.size()) { + return false; + } + for (qsizetype index = 0; index < first.size(); ++index) { + if (!near(first.at(index).value, second.at(index).value) + || first.at(index).label != second.at(index).label) { + return false; + } + } + return true; +} + +bool sameStaticScale(const AetherSDR::SMeterGeometry::StaticScale& first, + const AetherSDR::SMeterGeometry::StaticScale& second) +{ + return near(first.minimum, second.minimum) && near(first.maximum, second.maximum) + && near(first.warningStart, second.warningStart) + && first.hasWarning == second.hasWarning && sameTicks(first.ticks, second.ticks); +} + +bool sameGeometry(const AetherSDR::SMeterGeometry& first, + const AetherSDR::SMeterGeometry& second) +{ + if (first.formatVersion != second.formatVersion + || first.designVersion != second.designVersion + || first.sizing.preferred != second.sizing.preferred + || first.sizing.minimum != second.sizing.minimum + || !near(first.sizing.minimumAspectRatio, + second.sizing.minimumAspectRatio) + || !near(first.sizing.maximumAspectRatio, + second.sizing.maximumAspectRatio) + || !near(first.arc.centerXWidthFactor, second.arc.centerXWidthFactor) + || !near(first.arc.radiusWidthFactor, second.arc.radiusWidthFactor) + || !near(first.arc.centerYHeightFactor, second.arc.centerYHeightFactor) + || !near(first.arc.startDegrees, second.arc.startDegrees) + || !near(first.arc.endDegrees, second.arc.endDegrees) + || !near(first.arc.innerGapPixels, second.arc.innerGapPixels) + || !near(first.arc.lineWidthPixels, second.arc.lineWidthPixels) + || !near(first.tickStyle.startOffsetPixels, second.tickStyle.startOffsetPixels) + || !near(first.tickStyle.endOffsetPixels, second.tickStyle.endOffsetPixels) + || !near(first.tickStyle.labelOffsetPixels, second.tickStyle.labelOffsetPixels) + || !near(first.tickStyle.lineWidthPixels, second.tickStyle.lineWidthPixels) + || first.tickStyle.fontMinimumPixels != second.tickStyle.fontMinimumPixels + || !near(first.tickStyle.fontHeightFactor, second.tickStyle.fontHeightFactor) + || first.tickStyle.bold != second.tickStyle.bold + || !near(first.rxScale.minimumDbm, second.rxScale.minimumDbm) + || !near(first.rxScale.s9Dbm, second.rxScale.s9Dbm) + || !near(first.rxScale.maximumDbm, second.rxScale.maximumDbm) + || !near(first.rxScale.dbPerSUnit, second.rxScale.dbPerSUnit) + || !near(first.rxScale.s9Fraction, second.rxScale.s9Fraction) + || !sameTicks(first.rxScale.ticks, second.rxScale.ticks) + || !sameStaticScale(first.swrScale, second.swrScale) + || !sameStaticScale(first.levelScale, second.levelScale) + || !sameStaticScale(first.compressionScale, second.compressionScale) + || first.powerTickPolicies.size() != second.powerTickPolicies.size()) { + return false; + } + for (qsizetype index = 0; index < first.powerTickPolicies.size(); ++index) { + const AetherSDR::SMeterGeometry::PowerTickPolicy& firstPolicy = + first.powerTickPolicies.at(index); + const AetherSDR::SMeterGeometry::PowerTickPolicy& secondPolicy = + second.powerTickPolicies.at(index); + if (!near(firstPolicy.minimumScaleWatts, secondPolicy.minimumScaleWatts) + || firstPolicy.tickStepWatts != secondPolicy.tickStepWatts + || firstPolicy.labelStepWatts != secondPolicy.labelStepWatts) { + return false; + } + } + return near(first.needle.pivotYBelowWidgetPixels, + second.needle.pivotYBelowWidgetPixels) + && near(first.needle.tipExtensionPixels, second.needle.tipExtensionPixels) + && near(first.needle.lineWidthPixels, second.needle.lineWidthPixels) + && near(first.needle.shadowWidthPixels, second.needle.shadowWidthPixels) + && first.needle.shadowOffset == second.needle.shadowOffset + && near(first.pivot.minimumRadiusPixels, second.pivot.minimumRadiusPixels) + && near(first.pivot.radiusWidthFactor, second.pivot.radiusWidthFactor) + && near(first.pivot.glowRadiusFactor, second.pivot.glowRadiusFactor) + && near(first.pivot.glowMiddleFactor, second.pivot.glowMiddleFactor) + && first.pivot.glowCenterAlpha == second.pivot.glowCenterAlpha + && first.pivot.glowMiddleAlpha == second.pivot.glowMiddleAlpha + && near(first.pivot.rimWidthPixels, second.pivot.rimWidthPixels) + && near(first.peakMarker.radiusInsetPixels, second.peakMarker.radiusInsetPixels) + && near(first.peakMarker.lengthPixels, second.peakMarker.lengthPixels) + && near(first.peakMarker.halfWidthPixels, second.peakMarker.halfWidthPixels) + && near(first.peakMarker.minimumLeadDb, second.peakMarker.minimumLeadDb) + && near(first.peakHold.innerRadiusOffsetPixels, + second.peakHold.innerRadiusOffsetPixels) + && near(first.peakHold.outerRadiusOffsetPixels, + second.peakHold.outerRadiusOffsetPixels) + && near(first.peakHold.lineWidthPixels, second.peakHold.lineWidthPixels) + && near(first.peakHold.visibleAboveMinimumDb, + second.peakHold.visibleAboveMinimumDb) + && first.readout.sourceFontMinimumPixels == second.readout.sourceFontMinimumPixels + && near(first.readout.sourceFontHeightDivisor, + second.readout.sourceFontHeightDivisor) + && first.readout.valueFontMinimumPixels == second.readout.valueFontMinimumPixels + && near(first.readout.valueFontHeightDivisor, + second.readout.valueFontHeightDivisor) + && first.readout.topExtraPixels == second.readout.topExtraPixels + && first.readout.sideMarginPixels == second.readout.sideMarginPixels; +} + +AetherSDR::SMeterGeometry loadObject(const QJsonObject& object, QString* error) +{ + QByteArray bytes = QJsonDocument(object).toJson(QJsonDocument::Compact); + QBuffer buffer(&bytes); + buffer.open(QIODevice::ReadOnly); + return AetherSDR::SMeterGeometry::load(buffer, error); +} + +void expectRejected(const QJsonObject& object, const QString& expectedError, + const QString& message) +{ + QString error; + const AetherSDR::SMeterGeometry geometry = loadObject(object, &error); + expect(!geometry.isValid(), message + QStringLiteral(" is rejected")); + expect(error.contains(expectedError), + message + QStringLiteral(" reports its failing field or invariant")); +} + +double pointLineDistance(const QPointF& point, + const AetherSDR::SMeterGeometry::MovementRay& ray) +{ + const QPointF relative = point - ray.pivot; + return std::abs(relative.x() * ray.direction.y() + - relative.y() * ray.direction.x()); +} + +bool hasBrightPixelNear(const QImage& image, const QPointF& point, int radius) +{ + const int centerX = qRound(point.x()); + const int centerY = qRound(point.y()); + for (int y = centerY - radius; y <= centerY + radius; ++y) { + if (y < 0 || y >= image.height()) { + continue; + } + for (int x = centerX - radius; x <= centerX + radius; ++x) { + if (x < 0 || x >= image.width()) { + continue; + } + const QColor color = image.pixelColor(x, y); + if (color.red() >= 220 && color.green() >= 220 && color.blue() >= 220) { + return true; + } + } + } + return false; +} + +QByteArray imageDigest(const QImage& image) +{ + return QCryptographicHash::hash( + QByteArrayView(reinterpret_cast(image.constBits()), image.sizeInBytes()), + QCryptographicHash::Sha256); +} + +QImage render(AetherSDR::SMeterWidget& meter, const QSize& size) +{ + meter.resize(size); + meter.ensurePolished(); + QImage image(size, QImage::Format_ARGB32_Premultiplied); + image.fill(Qt::transparent); + QPainter painter(&image); + meter.render(&painter); + return image; +} + +int changedPixelCount(const QImage& image) +{ + const QRgb background = qRgb(0x0f, 0x0f, 0x1a); + int changed = 0; + for (int y = 0; y < image.height(); ++y) { + const QRgb* row = reinterpret_cast(image.constScanLine(y)); + for (int x = 0; x < image.width(); ++x) { + if ((row[x] & 0x00ffffffU) != (background & 0x00ffffffU)) { + ++changed; + } + } + } + return changed; +} + +double averageLuminance(const QImage& image, const QRect& requestedRect) +{ + const QRect rect = requestedRect.intersected(image.rect()); + if (rect.isEmpty()) { + return 0.0; + } + + double total = 0.0; + qsizetype count = 0; + for (int y = rect.top(); y <= rect.bottom(); ++y) { + for (int x = rect.left(); x <= rect.right(); ++x) { + const QColor color = image.pixelColor(x, y); + total += 0.2126 * color.red() + 0.7152 * color.green() + + 0.0722 * color.blue(); + ++count; + } + } + return count > 0 ? total / static_cast(count) : 0.0; +} + +QImage renderBackground(const AetherSDR::AnalogMeterFaceThemeCatalog& catalog, + AetherSDR::AnalogMeterFaceTheme theme, const QSize& size) +{ + QImage image(size, QImage::Format_ARGB32_Premultiplied); + image.fill(Qt::transparent); + QPainter painter(&image); + catalog.drawBackground( + painter, QRectF(QPointF(0.0, 0.0), QSizeF(size)), theme); + return image; +} + +void saveProofFromEnvironment(const char* variable, const QImage& image) +{ + const QString path = qEnvironmentVariable(variable); + if (!path.isEmpty()) { + expect(image.save(path), + QStringLiteral("proof image saves to %1").arg(path)); + } +} + +} // namespace + +int main(int argc, char** argv) +{ + TestSettingsProfile settingsProfile(QStringLiteral("aether-s-meter-geometry-test")); + if (!settingsProfile.isValid()) { + std::fprintf(stderr, "[FAIL] could not create temporary settings profile\n"); + return 1; + } + QApplication app(argc, argv); + AetherSDR::AppSettings::instance().load(); + + QString error; + const AetherSDR::SMeterGeometry geometry = + AetherSDR::SMeterGeometry::loadResource(&error); + expect(error.isEmpty(), "shipping S-meter resource loads without an error"); + expect(geometry.isValid(), "shipping S-meter geometry validates"); + expect(geometry.formatVersion == 1, "format version is 1"); + expect(geometry.designVersion == 5, + "design version includes bounded detached rendering"); + expect(geometry.sizing.preferred == QSize(280, 140), "preferred size is preserved"); + expect(geometry.sizing.minimum == QSize(200, 100), "minimum size is preserved"); + expect(near(geometry.sizing.minimumAspectRatio, 1.75) + && near(geometry.sizing.maximumAspectRatio, 4.0), + "detached face aspect limits are data driven"); + expect(sameGeometry(geometry, AetherSDR::SMeterGeometry::fallback()), + "shipping JSON and compiled fallback are exactly equivalent"); + expect(geometry.readout.topExtraPixels == 4, + "readout retains the approved top margin"); + for (const QSizeF size : + {QSizeF(200.0, 100.0), QSizeF(280.0, 140.0), + QSizeF(560.0, 280.0), QSizeF(200.0, 2000.0), + QSizeF(2000.0, 100.0)}) { + const AetherSDR::SMeterGeometry::Layout layout = + geometry.layoutFor(size); + QFont sourceFont = QApplication::font(); + sourceFont.setPixelSize(layout.sourceFontPixels); + const QFontMetrics sourceMetrics(sourceFont); + QFont valueFont = QApplication::font(); + valueFont.setPixelSize(layout.valueFontPixels); + valueFont.setBold(true); + const QFontMetrics valueMetrics(valueFont); + const int valueBaseline = + qRound(layout.viewport.top()) + + std::max(sourceMetrics.ascent(), valueMetrics.ascent()) + + geometry.readout.topExtraPixels; + const int sourceBaseline = + valueBaseline - valueMetrics.ascent() + + sourceMetrics.ascent(); + const int sourceTop = sourceBaseline - sourceMetrics.ascent(); + const int valueTop = valueBaseline - valueMetrics.ascent(); + const QString context = + QStringLiteral("%1x%2 readout").arg(size.width()).arg(size.height()); + expect(valueBaseline - valueMetrics.ascent() + >= qRound(layout.viewport.top()) + + geometry.readout.topExtraPixels, + context + QStringLiteral(" clears the value text from the top")); + expect(sourceTop == valueTop, + context + QStringLiteral(" top-aligns the source and values")); + expect(sourceBaseline < valueBaseline, + context + QStringLiteral(" shifts the smaller source label upward")); + } + + QString themeError; + const AetherSDR::AnalogMeterFaceThemeCatalog themes = + AetherSDR::AnalogMeterFaceThemeCatalog::loadResource(&themeError); + expect(themeError.isEmpty(), "shared analog face theme resource loads without an error"); + expect(themes.isValid(), "shared analog face theme resource validates"); + expect(themes.formatVersion == 1, "shared analog face theme format is versioned"); + QByteArray invalidThemeBytes("{}"); + QBuffer invalidThemeBuffer(&invalidThemeBytes); + invalidThemeBuffer.open(QIODevice::ReadOnly); + QString invalidThemeError; + expect(!AetherSDR::AnalogMeterFaceThemeCatalog::load( + invalidThemeBuffer, &invalidThemeError).isValid() + && !invalidThemeError.isEmpty(), + "malformed shared theme data is rejected with an error"); + const AetherSDR::AnalogMeterFaceThemeCatalog fallbackThemes = + AetherSDR::AnalogMeterFaceThemeCatalog::fallback(); + expect(fallbackThemes.isValid(), + "complete compiled theme fallback validates"); + expect(themes == fallbackThemes, + "shipping shared theme JSON and compiled fallback are exactly equivalent"); + + AetherSDR::AnalogMeterFaceThemeCatalog changedGradient = fallbackThemes; + changedGradient.uplightGradient.haloRadius += 0.01; + expect(changedGradient != fallbackThemes, + "theme equality includes every material gradient field"); + AetherSDR::AnalogMeterFaceThemeCatalog changedPalette = fallbackThemes; + changedPalette.darkPalette.needle = QColor(QStringLiteral("#123456")); + expect(changedPalette != fallbackThemes, + "theme equality includes every palette field"); + AetherSDR::AnalogMeterFaceThemeCatalog changedReference = fallbackThemes; + changedReference.referenceFace.translate(1.0, 0.0); + expect(changedReference != fallbackThemes, + "theme equality includes the reference face"); + AetherSDR::AnalogMeterFaceThemeCatalog changedMask = fallbackThemes; + changedMask.normalizedMaskBoundary[0].setY( + changedMask.normalizedMaskBoundary.at(0).y() - 0.01); + expect(changedMask != fallbackThemes, + "theme equality includes every lower-mask boundary point"); + + AetherSDR::AnalogMeterFaceThemeCatalog offCenterMask = fallbackThemes; + const qsizetype centerIndex = offCenterMask.normalizedMaskBoundary.size() / 2; + offCenterMask.normalizedMaskBoundary[centerIndex].setX(0.51); + QString offCenterMaskError; + expect(!offCenterMask.isValid(&offCenterMaskError) + && offCenterMaskError.contains(QStringLiteral("not symmetric")), + "off-center lower-mask midpoint is rejected as asymmetric"); + expect(themes.normalizedMaskBoundary.size() == 9, + "physical themes retain the approved nine-point lower mask"); + for (qsizetype index = 0; index < themes.normalizedMaskBoundary.size() / 2; + ++index) { + const QPointF left = themes.normalizedMaskBoundary.at(index); + const QPointF right = themes.normalizedMaskBoundary.at( + themes.normalizedMaskBoundary.size() - 1 - index); + expect(near(left.x() + right.x(), 1.0, 1e-9) + && near(left.y(), right.y(), 1e-9), + QStringLiteral("physical mask point pair %1 is symmetric").arg(index)); + } + + for (const QSizeF& size : {QSizeF(200.0, 100.0), QSizeF(280.0, 140.0), + QSizeF(560.0, 140.0), QSizeF(560.0, 280.0)}) { + const QRectF face(QPointF(0.0, 0.0), size); + const QVector boundary = themes.lowerMaskBoundary(face); + expect(boundary.size() == 9, + QStringLiteral("physical mask maps all points at %1x%2") + .arg(size.width()).arg(size.height())); + for (qsizetype index = 0; index < boundary.size() / 2; ++index) { + const QPointF left = boundary.at(index); + const QPointF right = boundary.at(boundary.size() - 1 - index); + expect(near(left.x() + right.x(), size.width(), 1e-7) + && near(left.y(), right.y(), 1e-7), + QStringLiteral("mapped mask pair %1 stays symmetric at %2x%3") + .arg(index).arg(size.width()).arg(size.height())); + } + expect(!themes.lowerMaskPath(face).isEmpty(), + QStringLiteral("physical mask path remains drawable at %1x%2") + .arg(size.width()).arg(size.height())); + } + + const QSize materialSize(560, 280); + const QImage classicMaterial = renderBackground( + themes, AetherSDR::AnalogMeterFaceTheme::ClassicWarm, materialSize); + const QImage uplightMaterial = renderBackground( + themes, AetherSDR::AnalogMeterFaceTheme::DarkRoomUplight, materialSize); + const QImage darkMaterial = renderBackground( + themes, AetherSDR::AnalogMeterFaceTheme::GraphiteDark, materialSize); + expect(imageDigest(classicMaterial) != imageDigest(uplightMaterial) + && imageDigest(uplightMaterial) != imageDigest(darkMaterial) + && imageDigest(classicMaterial) != imageDigest(darkMaterial), + "the three physical face materials are visually distinct"); + const QRect topCenter(210, 10, 140, 35); + const QRect lowerCenter(210, 220, 140, 35); + expect(averageLuminance(uplightMaterial, lowerCenter) + > averageLuminance(uplightMaterial, topCenter) + 45.0, + "dark-room uplight brightens realistically from top to lower center"); + expect(averageLuminance(darkMaterial, topCenter) < 80.0 + && averageLuminance(darkMaterial, lowerCenter) < 100.0, + "graphite material remains dark across the complete face"); + expect(imageDigest(uplightMaterial) + == imageDigest(renderBackground( + themes, AetherSDR::AnalogMeterFaceTheme::DarkRoomUplight, + materialSize)), + "fixed-seed paper grain renders deterministically"); + expect(themes.classicPalette.needle.lightness() < 70 + && themes.uplightPalette.needle.lightness() < 70, + "classic and uplight faces use approved dark needle material"); + expect(themes.darkPalette.needle.lightness() > 140, + "graphite face keeps its contrasting metallic needle material"); + + QFile resource(QStringLiteral(":/meterfaces/s-meter-v1.json")); + expect(resource.open(QIODevice::ReadOnly), "shipping S-meter JSON can be reopened"); + const QJsonDocument shippingDocument = QJsonDocument::fromJson(resource.readAll()); + expect(shippingDocument.isObject(), "shipping S-meter JSON has an object root"); + const QJsonObject shippingRoot = shippingDocument.object(); + + const AetherSDR::SMeterGeometry::Layout nominal = + geometry.layoutFor(QSizeF(280.0, 140.0)); + expect(near(nominal.centerX, 140.0), "nominal arc center X is preserved"); + expect(near(nominal.radius, 238.0), "nominal arc radius is preserved"); + expect(near(nominal.centerY, 287.0), "nominal arc center Y is preserved"); + expect(near(nominal.needlePivotY, 146.0), "nominal needle pivot Y is preserved"); + expect(near(nominal.innerRadius, 232.0), "nominal inner radius is preserved"); + expect(near(nominal.pivotRadius, 27.3), "nominal pivot radius is preserved"); + expect(nominal.tickFontPixels == 14, "nominal tick font is preserved"); + expect(nominal.sourceFontPixels == 10, "nominal source font is preserved"); + expect(nominal.valueFontPixels == 17, "nominal value font is preserved"); + + const AetherSDR::SMeterGeometry::Layout minimum = + geometry.layoutFor(QSizeF(200.0, 100.0)); + expect(near(minimum.centerX, 100.0), "minimum arc center X is responsive"); + expect(near(minimum.radius, 170.0), "minimum arc radius is responsive"); + expect(near(minimum.centerY, 205.0), "minimum arc center Y is responsive"); + expect(near(minimum.needlePivotY, 106.0), "minimum needle pivot is responsive"); + expect(near(minimum.pivotRadius, 19.5), "minimum pivot radius is responsive"); + expect(minimum.tickFontPixels == 10, "minimum tick font floor is preserved"); + expect(minimum.sourceFontPixels == 9, "minimum source font floor is preserved"); + expect(minimum.valueFontPixels == 13, "minimum value font floor is preserved"); + + const AetherSDR::SMeterGeometry::Layout wideShort = + geometry.layoutFor(QSizeF(560.0, 140.0)); + expect(near(wideShort.pivotRadius, nominal.pivotRadius), + "wide short windows do not inflate the pivot mask"); + const AetherSDR::SMeterGeometry::Layout uniformDouble = + geometry.layoutFor(QSizeF(560.0, 280.0)); + expect(near(uniformDouble.pivotRadius, nominal.pivotRadius * 2.0), + "pivot mask still scales with a uniformly enlarged face"); + const AetherSDR::SMeterGeometry::Layout tallNarrow = + geometry.layoutFor(QSizeF(280.0, 280.0)); + expect(near(tallNarrow.pivotRadius, nominal.pivotRadius), + "tall narrow windows keep the width-limited pivot mask"); + + const AetherSDR::SMeterGeometry::Layout extremeTall = + geometry.layoutFor(QSizeF(200.0, 2000.0)); + expect(near(extremeTall.viewport.left(), 0.0) + && near(extremeTall.viewport.top(), 6600.0 / 7.0) + && near(extremeTall.viewport.width(), 200.0) + && near(extremeTall.viewport.height(), 800.0 / 7.0), + "pathological tall windows center a minimum-aspect face viewport"); + expect(near(extremeTall.centerX, 100.0) + && near(extremeTall.radius, 170.0) + && near(extremeTall.centerY, 8070.0 / 7.0) + && near(extremeTall.needlePivotY, 7442.0 / 7.0), + "tall viewport keeps the movement pivot inside its printed arcs"); + expect(extremeTall.tickFontPixels == 11 + && extremeTall.sourceFontPixels == 9 + && extremeTall.valueFontPixels == 14, + "tall viewport bounds label growth before readout and scale text collide"); + + const AetherSDR::SMeterGeometry::Layout extremeWide = + geometry.layoutFor(QSizeF(2000.0, 100.0)); + expect(extremeWide.viewport == QRectF(800.0, 0.0, 400.0, 100.0), + "pathological wide windows center a maximum-aspect face viewport"); + expect(near(extremeWide.centerX, 1000.0) + && near(extremeWide.radius, 340.0) + && near(extremeWide.centerY, 375.0) + && near(extremeWide.needlePivotY, 106.0), + "wide viewport keeps every calibrated arc endpoint on the face"); + + expect(near(qRadiansToDegrees(geometry.fractionToRadians(0.0)), 125.0), + "fraction zero maps to the left endpoint"); + expect(near(qRadiansToDegrees(geometry.fractionToRadians(1.0)), 55.0), + "fraction one maps to the right endpoint"); + expect(near(geometry.rxFraction(-127.0), 0.0), "S0 maps to fraction zero"); + expect(near(geometry.rxFraction(-73.0), 0.6), "S9 maps to fraction 0.6"); + expect(near(geometry.rxFraction(-13.0), 1.0), "S9+60 maps to fraction one"); + expect(near(geometry.rxFraction(-200.0), 0.0), "RX scale clamps below S0"); + expect(near(geometry.rxFraction(20.0), 1.0), "RX scale clamps above S9+60"); + expect(near(geometry.scaleFraction(geometry.swrScale, 1.5), 0.25), + "SWR calibration is preserved"); + expect(near(geometry.scaleFraction(geometry.levelScale, 0.0), 40.0 / 45.0), + "level calibration is preserved"); + expect(near(geometry.scaleFraction(geometry.compressionScale, 12.5), 0.5), + "compression calibration is preserved"); + + const AetherSDR::SMeterGeometry::PowerTickPolicy& barefoot = + geometry.powerTickPolicy(120.0); + const AetherSDR::SMeterGeometry::PowerTickPolicy& aurora = + geometry.powerTickPolicy(600.0); + const AetherSDR::SMeterGeometry::PowerTickPolicy& amplifier = + geometry.powerTickPolicy(2000.0); + expect(barefoot.tickStepWatts == 10 && barefoot.labelStepWatts == 40, + "120 W power tick policy is preserved"); + expect(aurora.tickStepWatts == 50 && aurora.labelStepWatts == 100, + "600 W power tick policy is preserved"); + expect(amplifier.tickStepWatts == 100 && amplifier.labelStepWatts == 500, + "2 kW power tick policy is preserved"); + + const QPointF leftTip = geometry.needleTip(QSizeF(280.0, 140.0), 0.0); + const QPointF rightTip = geometry.needleTip(QSizeF(280.0, 140.0), 1.0); + expect(near(leftTip.x() + rightTip.x(), 280.0, 1e-8), + "needle endpoint construction is horizontally symmetric"); + expect(near(leftTip.y(), rightTip.y(), 1e-8), + "needle endpoint construction is vertically symmetric"); + + QVector calibratedFractions = {0.0, 0.5, 1.0}; + for (const AetherSDR::SMeterGeometry::Tick& tick : geometry.rxScale.ticks) { + calibratedFractions.push_back(geometry.rxFraction(tick.value)); + } + for (const AetherSDR::SMeterGeometry::StaticScale* scale : + {&geometry.swrScale, &geometry.levelScale, &geometry.compressionScale}) { + for (const AetherSDR::SMeterGeometry::Tick& tick : scale->ticks) { + calibratedFractions.push_back(geometry.scaleFraction(*scale, tick.value)); + } + } + + const QVector movementSizes = { + QSizeF(200.0, 100.0), QSizeF(260.0, 140.0), QSizeF(280.0, 140.0), + QSizeF(420.0, 140.0), QSizeF(560.0, 280.0), QSizeF(320.0, 180.0), + QSizeF(200.0, 2000.0), QSizeF(2000.0, 100.0)}; + for (const QSizeF& size : movementSizes) { + const AetherSDR::SMeterGeometry::Layout layout = geometry.layoutFor(size); + const QPointF center(layout.centerX, layout.centerY); + for (const double fraction : calibratedFractions) { + const AetherSDR::SMeterGeometry::MovementRay ray = + geometry.movementRayFor(size, fraction); + const QPointF tip = geometry.needleTip(size, fraction); + const std::optional innerPoint = + geometry.movementRayCircleIntersection(size, fraction, layout.innerRadius); + const QString context = QStringLiteral("%1x%2 at fraction %3") + .arg(size.width()) + .arg(size.height()) + .arg(fraction, 0, 'f', 4); + expect(near(std::hypot(ray.direction.x(), ray.direction.y()), 1.0, 1e-9), + context + QStringLiteral(" has a unit movement direction")); + expect(pointLineDistance(ray.scalePoint, ray) < 1e-8, + context + QStringLiteral(" outer scale point is on the movement ray")); + expect(pointLineDistance(tip, ray) < 1e-8, + context + QStringLiteral(" needle tip is on the movement ray")); + expect(near(std::hypot(tip.x() - ray.scalePoint.x(), + tip.y() - ray.scalePoint.y()), + geometry.needle.tipExtensionPixels, 1e-8), + context + QStringLiteral(" needle reaches the outer tick endpoint")); + expect(innerPoint.has_value(), + context + QStringLiteral(" intersects the inner TX arc")); + if (innerPoint) { + expect(pointLineDistance(*innerPoint, ray) < 1e-8, + context + QStringLiteral(" inner TX point is on the movement ray")); + expect(near(std::hypot(innerPoint->x() - center.x(), + innerPoint->y() - center.y()), + layout.innerRadius, 1e-8), + context + QStringLiteral(" inner TX point is on the printed arc")); + } + } + } + + QJsonObject missingNeedle = shippingRoot; + missingNeedle.remove(QStringLiteral("needle")); + expectRejected(missingNeedle, QStringLiteral("needle must be an object"), + QStringLiteral("missing needle section")); + + QJsonObject wrongArcType = shippingRoot; + QJsonObject wrongArc = wrongArcType.value(QStringLiteral("arc")).toObject(); + wrongArc.insert(QStringLiteral("start_degrees"), QStringLiteral("55")); + wrongArcType.insert(QStringLiteral("arc"), wrongArc); + expectRejected(wrongArcType, QStringLiteral("arc.start_degrees"), + QStringLiteral("mistyped arc field")); + + QJsonObject zeroSize = shippingRoot; + QJsonObject zeroSizing = zeroSize.value(QStringLiteral("sizing")).toObject(); + zeroSizing.insert(QStringLiteral("preferred"), QJsonArray{0, 0}); + zeroSizing.insert(QStringLiteral("minimum"), QJsonArray{0, 0}); + zeroSize.insert(QStringLiteral("sizing"), zeroSizing); + expectRejected(zeroSize, QStringLiteral("sizing is invalid"), + QStringLiteral("zero-sized face")); + + QJsonObject invalidAspect = shippingRoot; + QJsonObject invalidAspectSizing = + invalidAspect.value(QStringLiteral("sizing")).toObject(); + invalidAspectSizing.insert(QStringLiteral("minimum_aspect_ratio"), 5.0); + invalidAspect.insert(QStringLiteral("sizing"), invalidAspectSizing); + expectRejected(invalidAspect, QStringLiteral("sizing is invalid"), + QStringLiteral("inverted face aspect limits")); + + QJsonObject excludesPreferredAspect = shippingRoot; + QJsonObject excludesPreferredSizing = + excludesPreferredAspect.value(QStringLiteral("sizing")).toObject(); + excludesPreferredSizing.insert(QStringLiteral("minimum_aspect_ratio"), 2.25); + excludesPreferredAspect.insert(QStringLiteral("sizing"), excludesPreferredSizing); + expectRejected(excludesPreferredAspect, QStringLiteral("sizing is invalid"), + QStringLiteral("face aspect limits exclude preferred geometry")); + + QJsonObject negativeInnerRadius = shippingRoot; + QJsonObject oversizedGap = negativeInnerRadius.value(QStringLiteral("arc")).toObject(); + oversizedGap.insert(QStringLiteral("inner_gap_pixels"), 1000.0); + negativeInnerRadius.insert(QStringLiteral("arc"), oversizedGap); + expectRejected(negativeInnerRadius, QStringLiteral("derived layout is invalid"), + QStringLiteral("negative derived inner radius")); + + QJsonObject invalidGlow = shippingRoot; + QJsonObject shortGlow = invalidGlow.value(QStringLiteral("pivot")).toObject(); + shortGlow.insert(QStringLiteral("glow_radius_factor"), 1.0); + invalidGlow.insert(QStringLiteral("pivot"), shortGlow); + expectRejected(invalidGlow, QStringLiteral("needle or pivot is invalid"), + QStringLiteral("non-expanding pivot glow")); + + QJsonObject invalidCalibration = shippingRoot; + QJsonObject inconsistentRx = invalidCalibration.value(QStringLiteral("rx_scale")).toObject(); + inconsistentRx.insert(QStringLiteral("s9_dbm"), -72.0); + invalidCalibration.insert(QStringLiteral("rx_scale"), inconsistentRx); + expectRejected(invalidCalibration, QStringLiteral("RX scale is invalid"), + QStringLiteral("inconsistent S-unit calibration")); + + QByteArray malformedJson("{ definitely not JSON"); + QBuffer malformedBuffer(&malformedJson); + malformedBuffer.open(QIODevice::ReadOnly); + QString malformedError; + const AetherSDR::SMeterGeometry malformed = + AetherSDR::SMeterGeometry::load(malformedBuffer, &malformedError); + expect(!malformed.isValid(), "malformed JSON is rejected"); + expect(!malformedError.isEmpty(), "malformed JSON reports an error"); + expect(AetherSDR::SMeterGeometry::fallback().isValid(), "compiled fallback validates"); + + AetherSDR::SMeterWidget meter; + expect(meter.geometry().isValid(), "widget owns valid loaded geometry"); + expect(meter.sUnitsText() == QStringLiteral("S0"), "widget initializes at geometry S0"); + expect(meter.faceTheme() == AetherSDR::AnalogMeterFaceTheme::AetherDefault + && meter.faceThemeId() == QStringLiteral("aether-default"), + "existing Aether S-meter remains the new-user default"); + QAccessibleInterface* accessible = + QAccessible::queryAccessibleInterface(&meter); + expect(accessible && accessible->role() == QAccessible::Indicator, + "custom-painted S-meter exposes an accessible Indicator role"); + expect(accessible + && accessible->text(QAccessible::Value).contains(QStringLiteral("S0")), + "accessible S-meter value describes the calibrated reading"); + testAccessibilityAnnouncements(); + testRadioSwrValidityFilter(); + testRadioSwrValidityFilterAdaptsToSustainedLowerPower(); + expect(meter.sizePolicy().verticalPolicy() == QSizePolicy::Fixed, + "docked widget keeps its compact fixed-height policy"); + meter.setFloating(true); + expect(meter.sizePolicy().horizontalPolicy() == QSizePolicy::Expanding + && meter.sizePolicy().verticalPolicy() == QSizePolicy::Expanding, + "floating widget expands in both dimensions"); + meter.setFloating(false); + expect(meter.sizePolicy().horizontalPolicy() == QSizePolicy::Preferred + && meter.sizePolicy().verticalPolicy() == QSizePolicy::Fixed, + "re-docked widget restores its sidebar size policy"); + + const QSize liveSize(260, 140); + const QImage initial = render(meter, liveSize); + const AetherSDR::SMeterGeometry::MovementRay initialRay = + geometry.movementRayFor(QSizeF(liveSize), 0.0); + const QPointF initialTip = geometry.needleTip(QSizeF(liveSize), 0.0); + const QPointF alignedSample = initialRay.pivot + 0.70 * (initialTip - initialRay.pivot); + expect(hasBrightPixelNear(initial, alignedSample, 2), + "live-size rendering places the initial needle on its calibrated movement ray"); + + const AetherSDR::SMeterGeometry::Layout liveLayout = geometry.layoutFor(QSizeF(liveSize)); + const double oldAngle = geometry.fractionToRadians(0.0); + const double oldTipRadius = liveLayout.radius + geometry.needle.tipExtensionPixels; + const QPointF oldRadialTip(liveLayout.centerX + oldTipRadius * std::cos(oldAngle), + liveLayout.centerY - oldTipRadius * std::sin(oldAngle)); + const QPointF oldTrajectorySample = + initialRay.pivot + 0.70 * (oldRadialTip - initialRay.pivot); + expect(!hasBrightPixelNear(initial, oldTrajectorySample, 1), + "live-size rendering no longer uses the misaligned arc-center trajectory"); + + for (const QSize extremeSize : {QSize(200, 2000), QSize(2000, 100)}) { + const QImage extreme = render(meter, extremeSize); + const AetherSDR::SMeterGeometry::MovementRay ray = + geometry.movementRayFor(QSizeF(extremeSize), 0.0); + const QPointF tip = geometry.needleTip(QSizeF(extremeSize), 0.0); + const QPointF sample = ray.pivot + 0.70 * (tip - ray.pivot); + expect(hasBrightPixelNear(extreme, sample, 2), + QStringLiteral("%1x%2 render keeps the needle on its bounded movement ray") + .arg(extremeSize.width()) + .arg(extremeSize.height())); + } + + meter.setLevel(-73.0f); + QVector themedFaces; + const QVector physicalThemes = { + AetherSDR::AnalogMeterFaceTheme::ClassicWarm, + AetherSDR::AnalogMeterFaceTheme::DarkRoomUplight, + AetherSDR::AnalogMeterFaceTheme::GraphiteDark}; + for (const AetherSDR::AnalogMeterFaceTheme theme : physicalThemes) { + meter.setFaceTheme(theme); + const QImage first = render(meter, QSize(280, 140)); + const QImage second = render(meter, QSize(280, 140)); + const QString id = AetherSDR::analogMeterFaceThemeId(theme); + expect(meter.faceTheme() == theme && meter.faceThemeId() == id + && meter.property("faceTheme").toString() == id, + QStringLiteral("widget exposes selected %1 theme").arg(id)); + expect(imageDigest(first) == imageDigest(second), + QStringLiteral("%1 widget render is deterministic").arg(id)); + themedFaces.push_back(first); + + const QImage parked = [&meter, theme]() { + meter.setLevel(-127.0f); + meter.setFaceTheme(theme); + return render(meter, QSize(280, 140)); + }(); + meter.setLevel(-13.0f); + const QImage fullScale = render(meter, QSize(280, 140)); + expect(imageDigest(parked) != imageDigest(fullScale), + QStringLiteral("%1 needle moves across the calibrated sweep").arg(id)); + meter.setLevel(-73.0f); + } + expect(imageDigest(themedFaces.at(0)) != imageDigest(themedFaces.at(1)) + && imageDigest(themedFaces.at(1)) != imageDigest(themedFaces.at(2)) + && imageDigest(themedFaces.at(0)) != imageDigest(themedFaces.at(2)), + "all three physical S-meter themes remain visibly distinct"); + saveProofFromEnvironment("AETHER_S_METER_CLASSIC_PROOF", themedFaces.at(0)); + saveProofFromEnvironment("AETHER_S_METER_UPLIGHT_PROOF", themedFaces.at(1)); + saveProofFromEnvironment("AETHER_S_METER_DARK_PROOF", themedFaces.at(2)); + + meter.setFaceTheme(AetherSDR::AnalogMeterFaceTheme::DarkRoomUplight); + const QImage detachedWide = render(meter, QSize(560, 140)); + const QImage detachedLarge = render(meter, QSize(560, 280)); + expect(detachedWide.pixelColor(555, 70).alpha() == 255 + && detachedLarge.pixelColor(555, 140).alpha() == 255, + "physical face background fills resized detached windows"); + expect(imageDigest(detachedWide) != imageDigest(detachedLarge), + "physical face cache rebuilds for detached-window resize"); + + meter.setFaceTheme(AetherSDR::AnalogMeterFaceTheme::AetherDefault); + + const QImage rx = render(meter, QSize(280, 140)); + expect(changedPixelCount(rx) > 500, "RX face renders visible scale content"); + + meter.setTransmitting(true); + meter.setTxMode(QStringLiteral("Power")); + meter.setPowerScale(120, false); + meter.setTxMeters(60.0f, 1.5f); + const QImage power = render(meter, QSize(280, 140)); + + meter.setTxMode(QStringLiteral("SWR")); + const QImage swr = render(meter, QSize(280, 140)); + + meter.setTxMode(QStringLiteral("Level")); + meter.setMicMeters(-10.0f, 0.0f, -8.0f, 5.0f); + const QImage level = render(meter, QSize(280, 140)); + + meter.setTxMode(QStringLiteral("Compression")); + const QImage compression = render(meter, QSize(280, 140)); + + expect(imageDigest(power) != imageDigest(swr), "Power and SWR faces render differently"); + expect(imageDigest(swr) != imageDigest(level), "SWR and Level faces render differently"); + expect(imageDigest(level) != imageDigest(compression), + "Level and Compression faces render differently"); + + const QImage minimumRender = render(meter, QSize(200, 100)); + const QImage enlargedRender = render(meter, QSize(560, 280)); + expect(changedPixelCount(minimumRender) > 250, "minimum-size face renders"); + expect(changedPixelCount(enlargedRender) > 1000, "enlarged face renders"); + + if (g_failures == 0) { + std::cout << "All standard S-meter geometry checks passed\n"; + } + return g_failures == 0 ? 0 : 1; +} diff --git a/tests/shortcut_manager_test.cpp b/tests/shortcut_manager_test.cpp index 7d4dd58fc..b3855bc87 100644 --- a/tests/shortcut_manager_test.cpp +++ b/tests/shortcut_manager_test.cpp @@ -1,3 +1,4 @@ +#include "TestSettingsProfile.h" #include "core/AppSettings.h" #include "core/ShortcutManager.h" @@ -33,22 +34,15 @@ void registerCwActions(ShortcutManager& manager) int main(int argc, char** argv) { - QTemporaryDir fakeHome(QDir::tempPath() + "/aether-shortcut-manager-test-XXXXXX"); - if (!fakeHome.isValid()) { + TestSettingsProfile settingsProfile(QStringLiteral("aether-shortcut-manager-test")); + if (!settingsProfile.isValid()) { std::cerr << "[FAIL] create temporary home\n"; return 1; } - qputenv("HOME", fakeHome.path().toUtf8()); - qputenv("CFFIXED_USER_HOME", fakeHome.path().toUtf8()); - QStandardPaths::setTestModeEnabled(true); QCoreApplication app(argc, argv); - const QString configRoot = - QStandardPaths::writableLocation(QStandardPaths::ConfigLocation); - QDir(configRoot + "/AetherSDR").removeRecursively(); - auto& settings = AppSettings::instance(); - settings.reset(); + settings.load(); ShortcutManager manager; registerCwActions(manager); @@ -128,6 +122,7 @@ int main(int argc, char** argv) // reset to — its default is written absent; only user-changed keys stick. { settings.reset(); + settings.load(); ShortcutManager mgr; mgr.registerAction(QStringLiteral("band_up"), QStringLiteral("Band Up"), @@ -167,6 +162,7 @@ int main(int argc, char** argv) // its new (non-colliding) default rather than staying pinned to "". { settings.reset(); + settings.load(); ShortcutManager mgr; mgr.registerAction(QStringLiteral("alpha"), QStringLiteral("Alpha"), @@ -195,6 +191,5 @@ int main(int argc, char** argv) "resolved-collision default delivered (normalization not pinned)"); } - QDir(configRoot + "/AetherSDR").removeRecursively(); return ok ? 0 : 1; } diff --git a/tests/slice_label_test.cpp b/tests/slice_label_test.cpp index 5182432e4..921a365f9 100644 --- a/tests/slice_label_test.cpp +++ b/tests/slice_label_test.cpp @@ -1,3 +1,4 @@ +#include "TestSettingsProfile.h" #include "gui/SliceLabel.h" #include "core/AppSettings.h" @@ -29,6 +30,11 @@ static void setMode(const char* mode) int main() { + TestSettingsProfile settingsProfile(QStringLiteral("aether-slice-label-test")); + if (!settingsProfile.isValid()) { + return 1; + } + AppSettings::instance().load(); // ── Global mode (default) ───────────────────────────────────── setMode("Global"); diff --git a/tests/theme_manager_test.cpp b/tests/theme_manager_test.cpp index 22bd25360..b1d16abdc 100644 --- a/tests/theme_manager_test.cpp +++ b/tests/theme_manager_test.cpp @@ -1,3 +1,4 @@ +#include "TestSettingsProfile.h" #include "core/ThemeManager.h" #include "core/AppSettings.h" @@ -35,6 +36,11 @@ static int g_failures = 0; int main(int argc, char** argv) { + TestSettingsProfile settingsProfile(QStringLiteral("aether-theme-manager-test")); + if (!settingsProfile.isValid()) { + std::fprintf(stderr, "FAIL could not create isolated settings profile\n"); + return 1; + } // Route every QStandardPaths writable location into Qt's test-mode // sandbox (~/.qttest/...). XDG_CONFIG_HOME alone isn't enough: // macOS ignores it and resolves GenericConfigLocation to @@ -42,8 +48,6 @@ int main(int argc, char** argv) // probe themes into the developer's real themes dir — the leftover // files then collide on the next run and the import de-duplicates // the name to "... (2)", failing the EXPECT_EQ assertions below. - QStandardPaths::setTestModeEnabled(true); - // The sandbox itself persists across runs, so clear any probe // themes a previous (possibly crashed) run left behind before the // ThemeManager singleton scans the dir. @@ -55,12 +59,6 @@ int main(int argc, char** argv) QDir(sandboxAppDir).removeRecursively(); } - // Route AppSettings + theme dirs into an isolated temp tree so the - // test never pollutes the developer's real ~/.config/AetherSDR. - QTemporaryDir tmp; - EXPECT_TRUE(tmp.isValid()); - qputenv("XDG_CONFIG_HOME", tmp.path().toUtf8()); - QApplication app(argc, argv); QCoreApplication::setOrganizationName("AetherSDR-test"); QCoreApplication::setApplicationName("AetherSDR-test"); @@ -195,7 +193,8 @@ int main(int argc, char** argv) // that's Phase 5 work. The check below verifies the loadThemeFromPath // logic via the *file* layer though by writing the theme and re-reading // it through setActiveTheme's load path on a separate construction. - const QString userThemesDir = tmp.path() + "/AetherSDR-test/themes"; + const QString userThemesDir = + settingsProfile.path() + "/AetherSDR-test/themes"; QDir().mkpath(userThemesDir); QFile f(userThemesDir + "/test-theme.json"); if (f.open(QIODevice::WriteOnly)) { @@ -460,7 +459,8 @@ int main(int argc, char** argv) // (a font compound has no "type" field; a gradient has no "family" // field; a plain nested object has neither and must recurse). { - const QString discriminatorDir = tmp.path() + "/_discriminator_src"; + const QString discriminatorDir = + settingsProfile.path() + "/_discriminator_src"; QDir().mkpath(discriminatorDir); const QString discriminatorPath = discriminatorDir + "/discriminator.json"; @@ -659,7 +659,7 @@ int main(int argc, char** argv) // A sparse user theme that does not mention applet/* should still // inherit the built-in per-applet differentiation after load. { - const QString sparseDir = tmp.path() + "/_sparse_theme_src"; + const QString sparseDir = settingsProfile.path() + "/_sparse_theme_src"; QDir().mkpath(sparseDir); const QString sparsePath = sparseDir + "/sparse-theme.json"; QFile sf(sparsePath); @@ -736,7 +736,7 @@ int main(int argc, char** argv) // 2. the compound font persisted in {family, size, color} shape, // 3. unloading + reloading the theme produces identical values. { - const QString v1Dir = tmp.path() + "/_v1_src"; + const QString v1Dir = settingsProfile.path() + "/_v1_src"; QDir().mkpath(v1Dir); const QString v1Path = v1Dir + "/v1-source.json"; QFile v1(v1Path); diff --git a/tests/vu_meter_settings_test.cpp b/tests/vu_meter_settings_test.cpp index 19f65e8eb..9eb526915 100644 --- a/tests/vu_meter_settings_test.cpp +++ b/tests/vu_meter_settings_test.cpp @@ -27,12 +27,14 @@ void testNewUserDefaults() report("standard meter retains its established new-user defaults", vuSettings.txSelect == 0 && vuSettings.rxSelect == 0 && !vuSettings.peakHoldEnabled && - vuSettings.peakDecayRate == QStringLiteral("Medium")); + vuSettings.peakDecayRate == QStringLiteral("Medium") && + vuSettings.faceTheme == VuMeterSettingsCodec::kAetherTheme); const CrossNeedleMeterSettingsCodec::Snapshot powerSettings; - report("independent PWR meter defaults to dark-room uplight", + report("independent PWR meter defaults to uplight with Range visible", powerSettings.faceTheme == - CrossNeedleMeterSettingsCodec::kUplightTheme); + CrossNeedleMeterSettingsCodec::kUplightTheme && + powerSettings.showRange); } void testIndependentSettingsRoundTrip() @@ -42,6 +44,7 @@ void testIndependentSettingsRoundTrip() vuBefore.rxSelect = 1; vuBefore.peakHoldEnabled = true; vuBefore.peakDecayRate = QStringLiteral("Slow"); + vuBefore.faceTheme = VuMeterSettingsCodec::kDarkTheme; QString vuError; const VuMeterSettingsCodec::Snapshot vuAfter = @@ -51,25 +54,64 @@ void testIndependentSettingsRoundTrip() vuError.isEmpty() && vuAfter.txSelect == vuBefore.txSelect && vuAfter.rxSelect == vuBefore.rxSelect && vuAfter.peakHoldEnabled == vuBefore.peakHoldEnabled && - vuAfter.peakDecayRate == vuBefore.peakDecayRate); + vuAfter.peakDecayRate == vuBefore.peakDecayRate && + vuAfter.faceTheme == vuBefore.faceTheme); + + bool standardThemesPreserved = true; + for (const QString& theme : VuMeterSettingsCodec::faceThemeItems()) { + VuMeterSettingsCodec::Snapshot before; + before.faceTheme = theme; + QString error; + const VuMeterSettingsCodec::Snapshot after = + VuMeterSettingsCodec::decode( + VuMeterSettingsCodec::encode(before).toUtf8(), &error); + standardThemesPreserved = standardThemesPreserved + && error.isEmpty() && after.faceTheme == theme; + } + report("standard S-meter face themes round-trip", standardThemesPreserved); const QStringList themes{ CrossNeedleMeterSettingsCodec::kClassicTheme, CrossNeedleMeterSettingsCodec::kUplightTheme, CrossNeedleMeterSettingsCodec::kDarkTheme}; bool themesPreserved = true; + bool currentEncodingComplete = true; for (const QString& theme : themes) { CrossNeedleMeterSettingsCodec::Snapshot before; before.faceTheme = theme; + before.showRange = theme != CrossNeedleMeterSettingsCodec::kDarkTheme; + const QString encoded = + CrossNeedleMeterSettingsCodec::encode(before); + const QJsonObject encodedRoot = + QJsonDocument::fromJson(encoded.toUtf8()).object(); + currentEncodingComplete = currentEncodingComplete && + encodedRoot.value(QStringLiteral("version")).toInt() == + CrossNeedleMeterSettingsCodec::kVersion && + encodedRoot.value(QStringLiteral("showRange")).toBool() == + before.showRange; QString error; const CrossNeedleMeterSettingsCodec::Snapshot after = CrossNeedleMeterSettingsCodec::decode( - CrossNeedleMeterSettingsCodec::encode(before).toUtf8(), - &error); + encoded.toUtf8(), &error); themesPreserved = themesPreserved && error.isEmpty() && - after.faceTheme == theme; + after.faceTheme == theme && after.showRange == before.showRange; } - report("PWR applet face themes round-trip independently", themesPreserved); + report("PWR applet theme and Show Range round-trip independently", + themesPreserved); + report("PWR settings encode the current version and Show Range field", + currentEncodingComplete); + + QString legacyError; + const CrossNeedleMeterSettingsCodec::Snapshot versionOne = + CrossNeedleMeterSettingsCodec::decode( + QByteArrayLiteral( + "{\"version\":1,\"faceTheme\":\"graphite-dark\"}"), + &legacyError); + report("version-1 PWR settings migrate with Range visible", + legacyError.isEmpty() && + versionOne.faceTheme == + CrossNeedleMeterSettingsCodec::kDarkTheme && + versionOne.showRange); } void testCombinedVersionOneMigration() @@ -95,17 +137,45 @@ void testCombinedVersionOneMigration() const QJsonObject rewritten = QJsonDocument::fromJson( VuMeterSettingsCodec::encode(standard).toUtf8()).object(); - report("rewritten VuMeter object is version 2 and standard-only", - rewritten.value(QStringLiteral("version")).toInt() == 2 && + report("rewritten VuMeter object is version 3 and standard-only", + rewritten.value(QStringLiteral("version")).toInt() == 3 && !rewritten.contains(QStringLiteral("style")) && - !rewritten.contains(QStringLiteral("crossNeedle"))); + !rewritten.contains(QStringLiteral("crossNeedle")) && + rewritten.value(QStringLiteral("standard")).toObject() + .value(QStringLiteral("faceTheme")).toString() + == VuMeterSettingsCodec::kAetherTheme); const CrossNeedleMeterSettingsCodec::Snapshot migratedPower = CrossNeedleMeterSettingsCodec::migrateLegacyTheme( legacyCrossNeedle.faceTheme); report("legacy face theme moves to the independent PWR object", migratedPower.faceTheme == - CrossNeedleMeterSettingsCodec::kDarkTheme); + CrossNeedleMeterSettingsCodec::kDarkTheme && + migratedPower.showRange); +} + +void testVersionTwoMigration() +{ + const QByteArray versionTwo = QByteArrayLiteral( + "{\"version\":2,\"standard\":{\"txSelect\":3,\"rxSelect\":1," + "\"peakHoldEnabled\":true,\"peakDecayRate\":\"Slow\"}}"); + QString error; + const VuMeterSettingsCodec::Snapshot migrated = + VuMeterSettingsCodec::decode(versionTwo, &error); + report("version-2 standard settings migrate without changing controls", + error.isEmpty() && migrated.txSelect == 3 && migrated.rxSelect == 1 + && migrated.peakHoldEnabled + && migrated.peakDecayRate == QStringLiteral("Slow")); + report("version-2 settings adopt the established Aether face", + migrated.faceTheme == VuMeterSettingsCodec::kAetherTheme); + + const QByteArray invalidTheme = QByteArrayLiteral( + "{\"version\":3,\"standard\":{\"faceTheme\":\"not-a-theme\"}}"); + const VuMeterSettingsCodec::Snapshot bounded = + VuMeterSettingsCodec::decode(invalidTheme, &error); + report("unknown standard face themes fall back safely", + error.isEmpty() + && bounded.faceTheme == VuMeterSettingsCodec::kAetherTheme); } void testFlatLegacyMigration() @@ -117,7 +187,8 @@ void testFlatLegacyMigration() migrated.txSelect == VuMeterSettingsCodec::txMeterItems().size() - 1 && migrated.rxSelect == 0 && migrated.peakHoldEnabled && - migrated.peakDecayRate == QStringLiteral("Slow")); + migrated.peakDecayRate == QStringLiteral("Slow") && + migrated.faceTheme == VuMeterSettingsCodec::kAetherTheme); const VuMeterSettingsCodec::Snapshot invalidDecay = VuMeterSettingsCodec::migrateLegacy( @@ -135,7 +206,8 @@ void testInvalidPackedSettingsFailSafe() report("malformed VuMeter settings return safe defaults", !malformedError.isEmpty() && malformed.txSelect == 0 && malformed.rxSelect == 0 && - malformed.peakDecayRate == QStringLiteral("Medium")); + malformed.peakDecayRate == QStringLiteral("Medium") && + malformed.faceTheme == VuMeterSettingsCodec::kAetherTheme); QString versionError; const VuMeterSettingsCodec::Snapshot unsupported = @@ -151,7 +223,8 @@ void testInvalidPackedSettingsFailSafe() QByteArrayLiteral("{\"version\":99}"), &powerError); report("invalid PWR settings return the uplight default", !powerError.isEmpty() && badPower.faceTheme == - CrossNeedleMeterSettingsCodec::kUplightTheme); + CrossNeedleMeterSettingsCodec::kUplightTheme && + badPower.showRange); } } // namespace @@ -162,6 +235,7 @@ int main(int argc, char** argv) testNewUserDefaults(); testIndependentSettingsRoundTrip(); testCombinedVersionOneMigration(); + testVersionTwoMigration(); testFlatLegacyMigration(); testInvalidPackedSettingsFailSafe(); return g_failed == 0 ? 0 : 1; diff --git a/tools/fit_cross_needle_response.py b/tools/fit_cross_needle_response.py new file mode 100644 index 000000000..0b231f430 --- /dev/null +++ b/tools/fit_cross_needle_response.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python3 +"""Verify and diagnose the PWR meter's power-domain movement responses. + +The code-drawn face uses one degree-5 Bernstein polynomial per movement, in +normalized POWER (a real D'Arsonval movement driving a sqrt watt scale). The +needle parks on its printed zero (angled rest = reference_angles[0]), so every +photographed tick INCLUDING zero constrains the response. The stored responses +must satisfy: + +* b0 = 0 and b5 = 1; +* b0 <= ... <= b5 (monotonic movement); +* non-positive second differences (concave: sensitivity decreases with power, + so calibration noise cannot knot the SWR contours); +* every photographed tick reproduced within maximum_reference_error_pixels; +* each SWR contour has at most one gentle curvature inflection, and the low-SWR + contours reach the visible face above the mask (angled-rest behaviour that + mirrors a real cross-needle meter — see docs/cross-needle-meter-math.md, D1). + +After changing a response, run this tool AND the C++ construction test together. +""" + +from __future__ import annotations + +import argparse +import json +import math +from pathlib import Path +import sys + +DEGREE = 5 +COEFFICIENT_COUNT = DEGREE + 1 +MODEL = "concave_bernstein_v1" + + +def evaluate(coefficients: list[float], normalized_power: float) -> float: + # NOT clamped: SWR-contour construction passes values > 1 to extend a + # movement a little past full scale (matches the C++ angleForNormalizedPower). + t = normalized_power + work = coefficients[:] + for remaining in range(DEGREE, 0, -1): + for index in range(remaining): + work[index] = (1.0 - t) * work[index] + t * work[index + 1] + return work[0] + + +def angle_for_power(scale: dict, coefficients: list[float], power: float) -> float: + response = scale["response"] + normalized_power = power / scale["values"][-1] + deflection = evaluate(coefficients, normalized_power) + return (response["start_radians"] + + (response["end_radians"] - response["start_radians"]) * deflection) + + +def line_intersection(first_origin, first_angle, second_origin, second_angle): + first = math.cos(first_angle), math.sin(first_angle) + second = math.cos(second_angle), math.sin(second_angle) + denominator = first[0] * second[1] - first[1] * second[0] + delta = second_origin[0] - first_origin[0], second_origin[1] - first_origin[1] + distance = (delta[0] * second[1] - delta[1] * second[0]) / denominator + return (first_origin[0] + distance * first[0], + first_origin[1] + distance * first[1]) + + +def mask_boundary_y(boundary: list[list[float]], x: float) -> float: + for first, second in zip(boundary, boundary[1:]): + if first[0] <= x <= second[0]: + if second[0] == first[0]: + return min(first[1], second[1]) + fraction = (x - first[0]) / (second[0] - first[0]) + return first[1] + (second[1] - first[1]) * fraction + return boundary[0][1] if x < boundary[0][0] else boundary[-1][1] + + +def guide_points(root: dict, guide: dict) -> list[tuple[float, float]]: + forward = root["forward_scale"] + reflected = root["reflected_scale"] + fc = forward["response"]["coefficients"] + rc = reflected["response"]["coefficients"] + swr_value = guide["swr"] + swr = math.inf if swr_value == "infinity" else float(swr_value) + ratio = 1.0 if math.isinf(swr) else ((swr - 1.0) / (swr + 1.0)) ** 2 + forward_maximum = forward["values"][-1] + clearance = root["swr"]["graph_clearance"] + + # Mirror the C++ swrGuidePath: sweep from the hidden convergence, extending + # a movement past full scale as needed, to the first crossing of the common + # envelope a fixed clearance short of the nearer power arc. + def point(voltage: float) -> tuple[float, float]: + forward_power = forward_maximum * voltage * voltage + return line_intersection( + tuple(forward["center"]), angle_for_power(forward, fc, forward_power), + tuple(reflected["center"]), + angle_for_power(reflected, rc, forward_power * ratio)) + + def reaches(position: tuple[float, float]) -> bool: + return (math.dist(position, forward["center"]) >= forward["radius"] - clearance or + math.dist(position, reflected["center"]) >= reflected["radius"] - clearance) + + max_voltage = 1.6 + ending_voltage = max_voltage + previous = 0.0 + probes = 256 + for i in range(1, probes + 1): + v = max_voltage * i / probes + if reaches(point(v)): + lower, upper = previous, v + for _ in range(48): + middle = (lower + upper) * 0.5 + if reaches(point(middle)): + upper = middle + else: + lower = middle + ending_voltage = (lower + upper) * 0.5 + break + previous = v + + samples = root["swr"]["curve_samples"] + return [point(ending_voltage * s / samples) for s in range(samples + 1)] + + +def guide_diagnostics(root: dict, guide: dict) -> tuple[int, int]: + points = guide_points(root, guide) + boundary = root["mask"]["boundary"] + visible = sum(1 for p in points if p[1] < mask_boundary_y(boundary, p[0])) + curvatures: list[float] = [] + for index in range(2, len(points)): + midpoint = ((points[index - 1][0] + points[index][0]) * 0.5, + (points[index - 1][1] + points[index][1]) * 0.5) + if midpoint[1] >= mask_boundary_y(boundary, midpoint[0]): + continue + before, previous, current = points[index - 2:index + 1] + first = previous[0] - before[0], previous[1] - before[1] + second = current[0] - previous[0], current[1] - previous[1] + fl, sl = math.hypot(*first), math.hypot(*second) + if fl < 1e-9 or sl < 1e-9: + continue + turn = math.atan2(first[0] * second[1] - first[1] * second[0], + first[0] * second[0] + first[1] * second[1]) + curvatures.append(turn / ((fl + sl) * 0.5)) + smoothed = [] + for index in range(len(curvatures)): + window = [curvatures[min(max(index + o, 0), len(curvatures) - 1)] + for o in range(-2, 3)] + smoothed.append(sum(window) / len(window)) + threshold = max((abs(v) for v in smoothed), default=0.0) * 0.025 + previous_sign = 0 + reversals = 0 + for curvature in smoothed: + if abs(curvature) <= threshold: + continue + sign = 1 if curvature > 0.0 else -1 + if previous_sign and sign != previous_sign: + reversals += 1 + previous_sign = sign + return reversals, visible + + +def main() -> int: + default_geometry = (Path(__file__).resolve().parents[1] / + "resources/meterfaces/cross-needle-v12.json") + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("geometry", nargs="?", type=Path, default=default_geometry) + parser.add_argument("--check", action="store_true", + help="fail if movement or SWR mechanical constraints are violated") + arguments = parser.parse_args() + + with arguments.geometry.open("r", encoding="utf-8") as source: + root = json.load(source) + + valid = True + for name in ("forward_scale", "reflected_scale"): + scale = root[name] + response = scale["response"] + coefficients = response["coefficients"] + errors = [abs(angle_for_power(scale, coefficients, power) - angle) * scale["radius"] + for power, angle in zip(scale["values"], scale["reference_angles_radians"])] + maximum = max(errors) + rms = math.sqrt(sum(e * e for e in errors) / len(errors)) + print(f"{name}: coefficients={json.dumps(coefficients)}") + print(f"{name}: rms_reference_error_px={rms:.6f} max_reference_error_px={maximum:.6f}") + second_differences = [coefficients[i + 2] - 2.0 * coefficients[i + 1] + coefficients[i] + for i in range(DEGREE - 1)] + valid = valid and response["model"] == MODEL + valid = valid and len(coefficients) == COEFFICIENT_COUNT + valid = valid and abs(coefficients[0]) <= 1e-12 + valid = valid and abs(coefficients[-1] - 1.0) <= 1e-12 + valid = valid and all(a <= b + 1e-12 for a, b in zip(coefficients, coefficients[1:])) + valid = valid and all(d <= 1e-12 for d in second_differences) + valid = valid and maximum <= response["maximum_reference_error_pixels"] + + print("SWR guide diagnostics (reversals / visible samples):") + for guide in root["swr"]["guides"]: + reversals, visible = guide_diagnostics(root, guide) + print(f" {guide['label']}: reversals={reversals} visible_samples={visible}") + valid = valid and reversals <= 1 + # The low-SWR contours must reach the visible face like a real meter. + if guide["label"] in ("1.1", "1.2", "1.3"): + valid = valid and visible >= 3 + + if arguments.check and not valid: + print("cross-needle response check failed", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 318b783ae6e0d7724fe8d737852daa950e92eb75 Mon Sep 17 00:00:00 2001 From: "Jeremy [KK7GWY]" Date: Sat, 18 Jul 2026 09:07:39 -0700 Subject: [PATCH 2/2] fix(smeter): reset below-unity SWR window on interrupting recovery sample (#4274) The radio-native SWR validity filter's peg-to-full-scale confirmation could fire on a NON-continuous run of below-unity sentinels. The low-power recovery-held path advanced the recovery window but left m_belowUnityStartedAtMs untouched, so a genuine in-range reading landing between two sub-1.0 samples did not restart the below-unity window; a stale start time could then satisfy the confirmation and peg the meter to full scale on an interrupted streak. Reset m_belowUnityStartedAtMs in the recovery-held branch (rawSwr >= 1.0 there, so the sub-1.0 run is broken) while keeping the recovery window advancing. Adds testRadioSwrValidityFilterInterruptedBelowUnityDoesNotPeg, which fails without the fix. Also documents why the forward-power envelope is outside the AGENTS.md MeterSmoother rule: it gates sample trust and never smooths the displayed SWR. Addresses review findings on #4274. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/gui/RadioSwrValidityFilter.cpp | 5 +++++ src/gui/RadioSwrValidityFilter.h | 6 ++++++ tests/s_meter_geometry_test.cpp | 30 ++++++++++++++++++++++++++++++ 3 files changed, 41 insertions(+) diff --git a/src/gui/RadioSwrValidityFilter.cpp b/src/gui/RadioSwrValidityFilter.cpp index a6edbc707..841d081a4 100644 --- a/src/gui/RadioSwrValidityFilter.cpp +++ b/src/gui/RadioSwrValidityFilter.cpp @@ -86,6 +86,11 @@ RadioSwrValidityFilter::Result RadioSwrValidityFilter::update( clearTimedConfirmations(); } else { held = true; + // rawSwr >= 1.0 here, so this sample breaks any run of + // below-unity sentinels: reset that window (but keep the + // recovery window advancing) so an interrupted sub-1.0 streak + // cannot later peg the meter to full scale on a stale start. + m_belowUnityStartedAtMs = -1; } m_lowSwrCandidateSamples = 0; } else { diff --git a/src/gui/RadioSwrValidityFilter.h b/src/gui/RadioSwrValidityFilter.h index d78e95b30..94b55b26c 100644 --- a/src/gui/RadioSwrValidityFilter.h +++ b/src/gui/RadioSwrValidityFilter.h @@ -8,6 +8,12 @@ namespace AetherSDR { // power. The forward-power envelope attacks immediately and releases with // elapsed time, so brief modulation gaps cannot force SWR toward 1 while a // sustained lower-power operating point eventually becomes authoritative. +// +// Note on AGENTS.md "use MeterSmoother": the forward-power envelope here is a +// trust GATE (is there enough measurable carrier to believe this SWR sample?), +// not display smoothing — it never smooths the number shown on the meter. The +// displayed SWR is passed through verbatim once the gate accepts it, so this is +// deliberately outside the MeterSmoother rule, which governs the value drawn. class RadioSwrValidityFilter { public: struct Result { diff --git a/tests/s_meter_geometry_test.cpp b/tests/s_meter_geometry_test.cpp index 8721438de..ade552598 100644 --- a/tests/s_meter_geometry_test.cpp +++ b/tests/s_meter_geometry_test.cpp @@ -373,6 +373,35 @@ void testRadioSwrValidityFilterAdaptsToSustainedLowerPower() "reset filter ignores an unpowered first SWR sample"); } +void testRadioSwrValidityFilterInterruptedBelowUnityDoesNotPeg() +{ + // Regression: a run of below-unity sentinels that is broken by a genuine + // in-range reading must NOT peg the meter to full scale. The below-unity + // confirmation window has to restart when the streak is interrupted, + // otherwise a stale start time can fire the peg on a non-continuous run. + AetherSDR::RadioSwrValidityFilter filter; + + // Latch a 5.0 fault at full power. + filter.update(100.0f, 5.0f, 0, 6.0f); + + // A sub-unity sentinel under a weak carrier opens the below-unity window. + AetherSDR::RadioSwrValidityFilter::Result result = + filter.update(10.0f, 0.5f, 100, 6.0f); + expect(result.held && near(result.displayedSwr, 5.0), + "one below-unity sample under weak power is held, not pegged"); + + // A genuine 2.0 reading in the twilight zone interrupts the sentinel run. + result = filter.update(10.0f, 2.0f, 200, 6.0f); + expect(result.held && near(result.displayedSwr, 5.0), + "an interrupting in-range recovery sample is held"); + + // A later below-unity sample — long after the first one — must not peg to + // full scale, because the interrupting reading restarted the window. + result = filter.update(10.0f, 0.5f, 500, 6.0f); + expect(result.held && near(result.displayedSwr, 5.0), + "an interrupted below-unity streak cannot peg the meter to full scale"); +} + bool sameTicks(const QVector& first, const QVector& second) { @@ -1008,6 +1037,7 @@ int main(int argc, char** argv) testAccessibilityAnnouncements(); testRadioSwrValidityFilter(); testRadioSwrValidityFilterAdaptsToSustainedLowerPower(); + testRadioSwrValidityFilterInterruptedBelowUnityDoesNotPeg(); expect(meter.sizePolicy().verticalPolicy() == QSizePolicy::Fixed, "docked widget keeps its compact fixed-height policy"); meter.setFloating(true);