Take the scan waypoints only from the CSV file, never generate them

This commit is contained in:
pgdalmeida 2026-09-29 15:02:19 +02:00
parent ab9fa3dbb0
commit a21be0d4e8
Signed by: pedro.almeida
GPG Key ID: D4A6C394DF13F1D7
13 changed files with 168 additions and 122 deletions

View File

@ -206,19 +206,17 @@ homing_timeout_s = 65
[Scan] [Scan]
; Capture scan grid (the (yaw,pitch) waypoints auto-sweep steps through). ; Capture scan grid (the (yaw,pitch) waypoints auto-sweep steps through).
; If grid_file is set, that CSV is used verbatim (one "yaw_deg,pitch_deg" per ; grid_file is the ONLY source: that CSV is used verbatim (one
; line, '#' comments allowed) - edit it to define exact scan coordinates. ; "yaw_deg,pitch_deg" per line, '#' comments allowed). Nothing is generated -
; If grid_file is blank, a grid is generated: yaw_intervals positions across ; leave it blank and auto-sweep stays off with a message saying so, rather than
; [yaw_min_deg, yaw_max_deg] at each pitch level. ; the tower inventing an arc nobody chose.
; A relative path resolves against the directory of the config file it is
; written in (config.ini sits at the repo root, so: config/scan.csv).
; The sweep always runs front to back, then returns to the first waypoint and ; The sweep always runs front to back, then returns to the first waypoint and
; repeats, so every pass captures the waypoints in the same order. The return ; repeats, so every pass captures the waypoints in the same order. The return
; leg is one long slew (the yaw arc cannot wrap), so order the grid with that ; leg is one long slew (the yaw arc cannot wrap), so order the grid with that
; in mind. ; in mind.
grid_file = grid_file = config/scan.csv
yaw_intervals = 56
yaw_min_deg = -90
yaw_max_deg = 90
pitch_levels = 10,30,50
[Paths] [Paths]
; Directory for saved .jxl images. Supports leading ~ and $ENV expansion. ; Directory for saved .jxl images. Supports leading ~ and $ENV expansion.

View File

@ -0,0 +1,64 @@
# Staeffelsberg scan grid: 56 bearings round the full circle at one elevation,
# matching the old firmware's 'ud56' sweep. Generated once from the retired
# [Scan] grid generator (0 -> 353.57 deg in 56 steps, pitch 0) and kept here as
# the explicit list, because the CSV is now the only source of waypoints.
#
# The sweep runs first to last, then returns to the first and repeats - always
# the same order, never in reverse. The return leg is one long slew back across
# the arc; that is the price of every pass being directly comparable.
0.00,0
6.43,0
12.86,0
19.29,0
25.71,0
32.14,0
38.57,0
45.00,0
51.43,0
57.86,0
64.29,0
70.71,0
77.14,0
83.57,0
90.00,0
96.43,0
102.86,0
109.29,0
115.71,0
122.14,0
128.57,0
135.00,0
141.43,0
147.86,0
154.29,0
160.71,0
167.14,0
173.57,0
180.00,0
186.43,0
192.86,0
199.28,0
205.71,0
212.14,0
218.57,0
225.00,0
231.43,0
237.86,0
244.28,0
250.71,0
257.14,0
263.57,0
270.00,0
276.43,0
282.86,0
289.28,0
295.71,0
302.14,0
308.57,0
315.00,0
321.43,0
327.86,0
334.28,0
340.71,0
347.14,0
353.57,0
1 # Staeffelsberg scan grid: 56 bearings round the full circle at one elevation,
2 # matching the old firmware's 'ud56' sweep. Generated once from the retired
3 # [Scan] grid generator (0 -> 353.57 deg in 56 steps, pitch 0) and kept here as
4 # the explicit list, because the CSV is now the only source of waypoints.
5 #
6 # The sweep runs first to last, then returns to the first and repeats - always
7 # the same order, never in reverse. The return leg is one long slew back across
8 # the arc; that is the price of every pass being directly comparable.
9 0.00,0
10 6.43,0
11 12.86,0
12 19.29,0
13 25.71,0
14 32.14,0
15 38.57,0
16 45.00,0
17 51.43,0
18 57.86,0
19 64.29,0
20 70.71,0
21 77.14,0
22 83.57,0
23 90.00,0
24 96.43,0
25 102.86,0
26 109.29,0
27 115.71,0
28 122.14,0
29 128.57,0
30 135.00,0
31 141.43,0
32 147.86,0
33 154.29,0
34 160.71,0
35 167.14,0
36 173.57,0
37 180.00,0
38 186.43,0
39 192.86,0
40 199.28,0
41 205.71,0
42 212.14,0
43 218.57,0
44 225.00,0
45 231.43,0
46 237.86,0
47 244.28,0
48 250.71,0
49 257.14,0
50 263.57,0
51 270.00,0
52 276.43,0
53 282.86,0
54 289.28,0
55 295.71,0
56 302.14,0
57 308.57,0
58 315.00,0
59 321.43,0
60 327.86,0
61 334.28,0
62 340.71,0
63 347.14,0
64 353.57,0

View File

@ -1,24 +1,30 @@
# Scan grid — capture waypoints," one ""yaw_deg","pitch_deg"" per line." # Scan grid - capture waypoints, one "yaw_deg,pitch_deg" per line.
# Edit these to define the exact scan coordinates. Blank lines and lines,, # Edit these to define the exact scan coordinates. Blank lines and lines
# starting with '#' are ignored. The auto-sweep (ControlCode 0) walks the list,, # starting with '#' are ignored. This file is the only source of waypoints:
# forward then backward (ping-pong), triggering the cameras at each settled, # nothing is generated, so what the tower photographs is always what is written
# point. Degrees are converted to encoder counts via the [Motor] calibration.,, # here.
#,, #
# Reference this file from config.ini: [Scan] grid_file = config/scan.csv,, # The auto-sweep (ControlCode 0) walks the list from the first waypoint to the
#,, # last, triggering the cameras at each settled point, then returns to the first
# Example: three elevation rows across a 180° yaw arc.,, # and repeats - always in the same order, never in reverse. That return leg is
0,10, # one long slew (the yaw arc cannot wrap), so order the list with it in mind.
45,10, # Degrees are converted to encoder counts via the [Motor] calibration.
90,10, #
135,10, # Reference this file from config.ini: [Scan] grid_file = config/scan.csv
180,10, #
0,5, # Example: three elevation rows across a 180 deg yaw arc.
45,5, 0,10
90,5, 45,10
135,5, 90,10
180,5, 135,10
0,15, 180,10
45,15, 0,5
90,15, 45,5
135,15, 90,5
180,15, 135,5
180,5
0,15
45,15
90,15
135,15
180,15

Can't render this file because it contains an unexpected character in line 1 and column 38.

View File

@ -86,10 +86,7 @@ Parsed and validated by `ConfigLoader` ([src/core/Config.cpp](../src/core/Config
| `Motor.<axis>` | `enc_deviation` | int | — | \|XACTUAL−XENC\| that means "position untrustworthy". **hardware.ini** | | `Motor.<axis>` | `enc_deviation` | int | — | \|XACTUAL−XENC\| that means "position untrustworthy". **hardware.ini** |
| `Motor.<axis>` | `enc_const` | float | 12.8 | Microsteps per encoder count (the TMC's `ENC_CONST`), up to 4 decimals. **hardware.ini**, optional: only a drivetrain whose encoder differs declares it. Wrong by a few percent, the step counter and encoder drift apart as the axis turns, and homing takes the drift for a stop (staeffelsberg: 12.5, from its old firmware) | | `Motor.<axis>` | `enc_const` | float | 12.8 | Microsteps per encoder count (the TMC's `ENC_CONST`), up to 4 decimals. **hardware.ini**, optional: only a drivetrain whose encoder differs declares it. Wrong by a few percent, the step counter and encoder drift apart as the axis turns, and homing takes the drift for a stop (staeffelsberg: 12.5, from its old firmware) |
| `Motor.<axis>` | `sw_mode` / `has_encoder` | int / bool | — | TMC `SW_MODE` (physical endstop switches on REFL/REFR) and whether this axis's encoder is aligned and usable. **hardware.ini** | | `Motor.<axis>` | `sw_mode` / `has_encoder` | int / bool | — | TMC `SW_MODE` (physical endstop switches on REFL/REFR) and whether this axis's encoder is aligned and usable. **hardware.ini** |
| `Scan` | `grid_file` | string | — | CSV of `yaw_deg,pitch_deg` waypoints; empty → generate | | `Scan` | `grid_file` | string | — | CSV of `yaw_deg,pitch_deg` waypoints. The only source of a scan grid; empty → auto-sweep disabled |
| `Scan` | `yaw_intervals` | int | `56` | Generated yaw positions across `[yaw_min_deg, yaw_max_deg]` |
| `Scan` | `yaw_min_deg`/`yaw_max_deg` | float | `-90`/`90` | Generated yaw arc |
| `Scan` | `pitch_levels` | csv | `0` | Generated pitch elevations (deg) |
### `[Motor]` calibration & `[Scan]` grid ### `[Motor]` calibration & `[Scan]` grid
@ -179,15 +176,21 @@ the tower can measure it. Yaw is untouched by it.
#### Scan grid #### Scan grid
The capture **scan grid** is the ordered `(yaw,pitch)` waypoints auto-sweep visits, always front to The capture **scan grid** is the ordered `(yaw,pitch)` waypoints auto-sweep visits, always front to
back. Set `[Scan] grid_file` to an editable CSV ([config/scan.csv](../config/scan.csv)) to define exact back, then round to the first again — never in reverse. `[Scan] grid_file` points at an editable CSV
coordinates, or leave it blank to generate `yaw_intervals × pitch_levels` points. ([config/scan.csv](../config/scan.csv)) and is the **only** source of waypoints.
**Nothing is generated.** Leave `grid_file` blank (or name a file that is missing, malformed or
empty) and the program logs why and leaves auto-sweep off, showing the reason in the TUI's scan-grid
panel. A tower that invented its own arc would archive images under bearings nobody chose, with no
file to read back to find out what was photographed. The pre-2026-09 keys `yaw_intervals`,
`yaw_min_deg`, `yaw_max_deg` and `pitch_levels` are gone; a config still carrying them gets a
startup warning naming them.
**Grid yaw values are true bearings.** Yaw travel is under a full turn, so there is always an **Grid yaw values are true bearings.** Yaw travel is under a full turn, so there is always an
unreachable arc; waypoints falling in it are **skipped** with a warning rather than clamped to the unreachable arc; waypoints falling in it are **skipped** with a warning rather than clamped to the
nearest limit — clamping would image a direction nobody asked for and publish it under the bearing nearest limit — clamping would image a direction nobody asked for and publish it under the bearing
they did. A one-line audit after each home reports how many waypoints are affected. Generated grids they did. A one-line audit after each home reports how many waypoints are affected. Write an arc
interpolate `yaw_min_deg → yaw_max_deg` linearly and wrap each point, so an arc crossing north is crossing north monotonically (`340` → `380`) rather than letting it wrap back through zero.
written monotonically (`340` → `380`).
Each camera's output subfolder is its `[Camera.<name>] label` (defaulting to the block name); nothing is derived from the order of the blocks. Each camera's output subfolder is its `[Camera.<name>] label` (defaulting to the block name); nothing is derived from the order of the blocks.

View File

@ -11,7 +11,7 @@ Per-file reference for the refactored tree, plus the shared data structures.
| [include/fgc/Logger.h](../include/fgc/Logger.h), [src/core/Logger.cpp](../src/core/Logger.cpp) | Leveled, thread-safe logger + per-category wire trace; `LOG_TRACE..LOG_ERROR`, `LOG_TRACE_CAT` | | [include/fgc/Logger.h](../include/fgc/Logger.h), [src/core/Logger.cpp](../src/core/Logger.cpp) | Leveled, thread-safe logger + per-category wire trace; `LOG_TRACE..LOG_ERROR`, `LOG_TRACE_CAT` |
| [include/fgc/FileLogSink.h](../include/fgc/FileLogSink.h), [src/core/FileLogSink.cpp](../src/core/FileLogSink.cpp) | The persistent log file: append, size rotation, count-based pruning, a self-describing header per file. Runs inside `Logger`'s non-recursive lock, so it never logs - failures latch into `failed()`/`lastError()` for the caller to report | | [include/fgc/FileLogSink.h](../include/fgc/FileLogSink.h), [src/core/FileLogSink.cpp](../src/core/FileLogSink.cpp) | The persistent log file: append, size rotation, count-based pruning, a self-describing header per file. Runs inside `Logger`'s non-recursive lock, so it never logs - failures latch into `failed()`/`lastError()` for the caller to report |
| [include/fgc/Geometry.h](../include/fgc/Geometry.h), [src/core/Geometry.cpp](../src/core/Geometry.cpp) | Per-axis degrees↔encoder-counts affine map (`[Motor]` calibration) | | [include/fgc/Geometry.h](../include/fgc/Geometry.h), [src/core/Geometry.cpp](../src/core/Geometry.cpp) | Per-axis degrees↔encoder-counts affine map (`[Motor]` calibration) |
| [include/fgc/ScanGrid.h](../include/fgc/ScanGrid.h), [src/core/ScanGrid.cpp](../src/core/ScanGrid.cpp) | Capture waypoints (CSV or generated) + one-way wrapping cursor (`[Scan]`) | | [include/fgc/ScanGrid.h](../include/fgc/ScanGrid.h), [src/core/ScanGrid.cpp](../src/core/ScanGrid.cpp) | Capture waypoints (CSV only, never generated) + one-way wrapping cursor (`[Scan]`) |
| [include/fgc/TelemetryParser.h](../include/fgc/TelemetryParser.h), [src/core/TelemetryParser.cpp](../src/core/TelemetryParser.cpp) | `parseTelemetryLine` (firmware `ST` line) → `std::optional<MotorTelemetry>` | | [include/fgc/TelemetryParser.h](../include/fgc/TelemetryParser.h), [src/core/TelemetryParser.cpp](../src/core/TelemetryParser.cpp) | `parseTelemetryLine` (firmware `ST` line) → `std::optional<MotorTelemetry>` |
| [include/fgc/CommandParser.h](../include/fgc/CommandParser.h), [src/core/CommandParser.cpp](../src/core/CommandParser.cpp) | `parseCommand` whitespace tokenizer → `Command` | | [include/fgc/CommandParser.h](../include/fgc/CommandParser.h), [src/core/CommandParser.cpp](../src/core/CommandParser.cpp) | `parseCommand` whitespace tokenizer → `Command` |
| [include/fgc/HelpText.h](../include/fgc/HelpText.h), [src/core/HelpText.cpp](../src/core/HelpText.cpp) | Operator command catalog (`helpCatalog`) + `renderHelp` (console & TUI help) | | [include/fgc/HelpText.h](../include/fgc/HelpText.h), [src/core/HelpText.cpp](../src/core/HelpText.cpp) | Operator command catalog (`helpCatalog`) + `renderHelp` (console & TUI help) |

View File

@ -317,14 +317,11 @@ struct UiConfig {
}; };
// [Scan]: source of the capture scan grid (the (yaw,pitch) waypoints the // [Scan]: source of the capture scan grid (the (yaw,pitch) waypoints the
// auto-sweep steps through). If grid_file is set, the CSV is loaded verbatim; // auto-sweep steps through). The CSV is the only source - it is loaded verbatim
// otherwise a grid is generated from the parameters below (see ScanGrid). // and there is no generated fallback, so what a tower photographs is always a
// file somebody wrote and can be read back (see ScanGrid).
struct ScanConfig { struct ScanConfig {
std::string grid_file; // path to an editable yaw_deg,pitch_deg CSV; empty => generate std::string grid_file; // path to an editable yaw_deg,pitch_deg CSV; required for auto-sweep
int yaw_intervals = 56; // generated: yaw positions across [yaw_min_deg, yaw_max_deg]
double yaw_min_deg = -90.0;
double yaw_max_deg = 90.0;
std::string pitch_levels = "0"; // generated: comma list of pitch elevations (deg)
}; };
// [Test] / [TestProfile.<name>]: the hardware self-test command. A profile is a // [Test] / [TestProfile.<name>]: the hardware self-test command. A profile is a

View File

@ -22,15 +22,17 @@ struct ScanPoint {
// The cost is a long return slew from the last waypoint to the first, since the // The cost is a long return slew from the last waypoint to the first, since the
// yaw arc is bounded and cannot wrap. Order the grid so that leg is acceptable. // yaw arc is bounded and cannot wrap. Order the grid so that leg is acceptable.
// //
// Points come either from an operator-edited CSV (ScanConfig::grid_file) or, if // Points come from an operator-edited CSV (ScanConfig::grid_file) and nowhere
// none is given, are generated from the [Scan] parameters. // else. There is deliberately no fallback grid: a tower that invented its own
// waypoints would archive images under bearings nobody chose, and the operator
// would have no file to point at to find out what was photographed.
class ScanGrid { class ScanGrid {
public: public:
ScanGrid() = default; ScanGrid() = default;
explicit ScanGrid(std::vector<ScanPoint> points) : points_(std::move(points)) {} explicit ScanGrid(std::vector<ScanPoint> points) : points_(std::move(points)) {}
// Build from config: load grid_file if set (throws std::runtime_error on a // Load grid_file. Throws std::runtime_error if it is unset, missing,
// missing/!malformed file), else generate from the parameters. // malformed or empty; the caller reports that and leaves auto-sweep off.
static ScanGrid fromConfig(const ScanConfig& cfg); static ScanGrid fromConfig(const ScanConfig& cfg);
bool empty() const { return points_.empty(); } bool empty() const { return points_.empty(); }
@ -50,6 +52,5 @@ private:
// Exposed for unit testing. // Exposed for unit testing.
std::vector<ScanPoint> parseScanCsv(const std::string& text); std::vector<ScanPoint> parseScanCsv(const std::string& text);
std::vector<ScanPoint> generateScanGrid(const ScanConfig& cfg);
} // namespace fgc } // namespace fgc

View File

@ -157,7 +157,6 @@ struct CaptureView {
// Defined scan grid (the auto-mode waypoints) with the live cursor // Defined scan grid (the auto-mode waypoints) with the live cursor
// flagged, surfaced in the expanded camera view. // flagged, surfaced in the expanded camera view.
std::vector<ScanWaypointView> scan_grid; std::vector<ScanWaypointView> scan_grid;
bool scan_from_file = false; // grid_file CSV vs generated
bool scan_pitch = false; // 2-axis rig (show pitch column) bool scan_pitch = false; // 2-axis rig (show pitch column)
std::string scan_error; // grid_file load failure (auto-sweep off) std::string scan_error; // grid_file load failure (auto-sweep off)

View File

@ -925,7 +925,6 @@ struct Application::Impl {
// Defined scan grid + live cursor (read on the control thread, same as the // Defined scan grid + live cursor (read on the control thread, same as the
// scheduler that mutates it, so no locking is needed). // scheduler that mutates it, so no locking is needed).
s.capture.scan_from_file = !cfg.scan.grid_file.empty();
s.capture.scan_pitch = t.pitch_present; s.capture.scan_pitch = t.pitch_present;
s.capture.scan_error = scan_load_error_; s.capture.scan_error = scan_load_error_;
s.capture.scan_grid.clear(); s.capture.scan_grid.clear();

View File

@ -521,12 +521,8 @@ AppConfig ConfigLoader::fromMap(const std::map<std::string, std::string>& kv) {
axisDrivetrain("yaw", cfg.drivetrain.yaw); axisDrivetrain("yaw", cfg.drivetrain.yaw);
axisDrivetrain("pitch", cfg.drivetrain.pitch); axisDrivetrain("pitch", cfg.drivetrain.pitch);
// [Scan]: scan-grid source (explicit CSV file, else generated). // [Scan]: the scan-grid CSV. No file, no auto-sweep - nothing is generated.
cfg.scan.grid_file = get(kv, "Scan.grid_file", cfg.scan.grid_file); cfg.scan.grid_file = get(kv, "Scan.grid_file", cfg.scan.grid_file);
cfg.scan.yaw_intervals = getInt(kv, "Scan.yaw_intervals", cfg.scan.yaw_intervals);
cfg.scan.yaw_min_deg = getDouble(kv, "Scan.yaw_min_deg", cfg.scan.yaw_min_deg);
cfg.scan.yaw_max_deg = getDouble(kv, "Scan.yaw_max_deg", cfg.scan.yaw_max_deg);
cfg.scan.pitch_levels = get(kv, "Scan.pitch_levels", cfg.scan.pitch_levels);
// ---- validation ---- // ---- validation ----
if (cfg.general.image_interval <= 0) if (cfg.general.image_interval <= 0)
@ -601,6 +597,20 @@ AppConfig ConfigLoader::fromMap(const std::map<std::string, std::string>& kv) {
"setup'), and drivetrain keys belong under [Motor.yaw] / " "setup'), and drivetrain keys belong under [Motor.yaw] / "
"[Motor.pitch], not [Motor]"; "[Motor.pitch], not [Motor]";
// Keys from when a blank grid_file meant "generate a grid". The generator is
// gone: the CSV is the only source. Ignoring these quietly would leave an
// operator believing they had set the scan arc when nothing reads it.
static const char* kRemovedScanKeys[] = {
"Scan.yaw_intervals", "Scan.yaw_min_deg", "Scan.yaw_max_deg", "Scan.pitch_levels",
};
std::string stale_scan;
for (const char* k : kRemovedScanKeys)
if (kv.count(k)) stale_scan += (stale_scan.empty() ? "" : ", ") + std::string(k + 5);
if (!stale_scan.empty())
LOG_WARN << "[Scan] ignoring keys nothing reads: " << stale_scan
<< " -- grids are no longer generated; list the waypoints in the "
"grid_file CSV instead";
return cfg; return cfg;
} }

View File

@ -16,20 +16,6 @@ std::string trim(const std::string& s) {
return s.substr(b, e - b + 1); return s.substr(b, e - b + 1);
} }
std::vector<double> parseDoubleList(const std::string& csv) {
std::vector<double> out;
size_t start = 0;
while (start <= csv.size()) {
size_t comma = csv.find(',', start);
std::string tok =
trim(csv.substr(start, comma == std::string::npos ? std::string::npos : comma - start));
if (!tok.empty()) out.push_back(std::stod(tok));
if (comma == std::string::npos) break;
start = comma + 1;
}
return out;
}
} // namespace } // namespace
std::vector<ScanPoint> parseScanCsv(const std::string& text) { std::vector<ScanPoint> parseScanCsv(const std::string& text) {
@ -58,37 +44,21 @@ std::vector<ScanPoint> parseScanCsv(const std::string& text) {
return pts; return pts;
} }
std::vector<ScanPoint> generateScanGrid(const ScanConfig& cfg) {
std::vector<ScanPoint> pts;
std::vector<double> pitches = parseDoubleList(cfg.pitch_levels);
if (pitches.empty()) pitches.push_back(0.0);
int n = cfg.yaw_intervals;
if (n < 1) n = 1;
// pitch-major, yaw inner (min -> max); the runtime cursor walks the whole list in order.
for (double pitch : pitches) {
for (int i = 0; i < n; ++i) {
double frac = n > 1 ? static_cast<double>(i) / (n - 1) : 0.0;
ScanPoint p;
p.yaw_deg = cfg.yaw_min_deg + frac * (cfg.yaw_max_deg - cfg.yaw_min_deg);
p.pitch_deg = pitch;
pts.push_back(p);
}
}
return pts;
}
ScanGrid ScanGrid::fromConfig(const ScanConfig& cfg) { ScanGrid ScanGrid::fromConfig(const ScanConfig& cfg) {
if (!cfg.grid_file.empty()) { // No grid_file, no grid. Generating a default sweep here would put a tower
std::ifstream f(cfg.grid_file); // on the air photographing bearings nobody picked, under a configuration
if (!f) throw std::runtime_error("cannot open scan grid file: " + cfg.grid_file); // that shows no waypoints at all; refusing leaves auto-sweep off and says
std::stringstream buf; // exactly what is missing.
buf << f.rdbuf(); if (cfg.grid_file.empty())
auto pts = parseScanCsv(buf.str()); throw std::runtime_error("no scan grid configured: set [Scan] grid_file to a CSV of "
if (pts.empty()) "'yaw_deg,pitch_deg' waypoints (see config/scan.csv)");
throw std::runtime_error("scan grid file has no waypoints: " + cfg.grid_file); std::ifstream f(cfg.grid_file);
return ScanGrid(std::move(pts)); if (!f) throw std::runtime_error("cannot open scan grid file: " + cfg.grid_file);
} std::stringstream buf;
return ScanGrid(generateScanGrid(cfg)); buf << f.rdbuf();
auto pts = parseScanCsv(buf.str());
if (pts.empty()) throw std::runtime_error("scan grid file has no waypoints: " + cfg.grid_file);
return ScanGrid(std::move(pts));
} }
void ScanGrid::advance() { void ScanGrid::advance() {

View File

@ -501,8 +501,7 @@ Element cameraDetailPanel(const CaptureView& c, ScrollState& st) {
// --- Scan grid --- // --- Scan grid ---
rows.push_back(separator()); rows.push_back(separator());
rows.push_back(hbox({text("SCAN GRID ") | bold | color(Color::Blue), rows.push_back(hbox({text("SCAN GRID ") | bold | color(Color::Blue), filler(),
text(c.scan_from_file ? "(CSV file)" : "(generated)") | dim, filler(),
text(std::to_string(c.scan_grid.size()) + " waypoints") | dim})); text(std::to_string(c.scan_grid.size()) + " waypoints") | dim}));
if (!c.scan_error.empty()) { if (!c.scan_error.empty()) {

View File

@ -2,6 +2,8 @@
#include "fgc/ScanGrid.h" #include "fgc/ScanGrid.h"
#include <stdexcept>
using namespace fgc; using namespace fgc;
TEST_CASE("parseScanCsv reads waypoints and skips comments/blanks") { TEST_CASE("parseScanCsv reads waypoints and skips comments/blanks") {
@ -17,20 +19,18 @@ TEST_CASE("parseScanCsv throws on a malformed line") {
CHECK_THROWS(parseScanCsv("1,abc\n")); CHECK_THROWS(parseScanCsv("1,abc\n"));
} }
TEST_CASE("generateScanGrid produces yaw_intervals x pitch_levels points") { TEST_CASE("fromConfig refuses to invent a grid when no file is named") {
// The generator is gone on purpose: a blank grid_file must leave auto-sweep
// off, not produce waypoints nobody chose.
ScanConfig c; ScanConfig c;
c.yaw_intervals = 3; CHECK(c.grid_file.empty());
c.yaw_min_deg = -90; CHECK_THROWS_AS(ScanGrid::fromConfig(c), std::runtime_error);
c.yaw_max_deg = 90; }
c.pitch_levels = "10,30";
auto pts = generateScanGrid(c); TEST_CASE("fromConfig reports a grid_file that is not there") {
REQUIRE(pts.size() == 6); ScanConfig c;
// pitch-major, yaw inner (min -> max) c.grid_file = "/nonexistent/definitely-not-a-scan-grid.csv";
CHECK(pts[0].yaw_deg == doctest::Approx(-90.0)); CHECK_THROWS_AS(ScanGrid::fromConfig(c), std::runtime_error);
CHECK(pts[0].pitch_deg == doctest::Approx(10.0));
CHECK(pts[1].yaw_deg == doctest::Approx(0.0));
CHECK(pts[2].yaw_deg == doctest::Approx(90.0));
CHECK(pts[3].pitch_deg == doctest::Approx(30.0));
} }
TEST_CASE("ScanGrid cursor always runs front to back") { TEST_CASE("ScanGrid cursor always runs front to back") {