Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,12 @@ concurrency:

jobs:
gates:
uses: resq-software/.github/.github/workflows/required.yml@f4b51a620aa1bf89c0bce4f434b36f92ff7d517d
uses: resq-software/.github/.github/workflows/required.yml@109c36b59b6c94e235e4590598fd5f719d7d321a
with:
lang: dotnet
dotnet-solution: ResQ.Viz.sln
codeql-languages: '["csharp"]'
submodules: recursive
secrets: inherit

required:
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/security.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,5 @@ jobs:
uses: resq-software/.github/.github/workflows/security-scan.yml@main
with:
languages: '["csharp","actions"]'
submodules: recursive
secrets: inherit
2 changes: 1 addition & 1 deletion lib/dotnet-sdk
6 changes: 3 additions & 3 deletions src/ResQ.Viz.Web/Controllers/SimController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -112,12 +112,12 @@ public IActionResult SendCommand(string id, [FromBody] DroneCommandRequest reque
FlightCommand command = request.Type.ToLowerInvariant() switch
{
"hover" => FlightCommand.Hover(),
"rtl" => FlightCommand.RTL(),
"land" => FlightCommand.Land(),
"rtl" => FlightCommand.RTL(),
"land" => FlightCommand.Land(),
"goto" when request.Target is { Length: 3 } =>
FlightCommand.GoTo(new Vector3(request.Target[0], request.Target[1], request.Target[2])),
"goto" => default, // handled below
_ => default,
_ => default,
};

if (request.Type.ToLowerInvariant() == "goto" && request.Target is not { Length: 3 })
Expand Down
2 changes: 1 addition & 1 deletion src/ResQ.Viz.Web/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

using System.Threading.RateLimiting;
using Microsoft.AspNetCore.RateLimiting;
using Vite.AspNetCore.Extensions;
using Vite.AspNetCore;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSignalR();
Expand Down
2 changes: 1 addition & 1 deletion src/ResQ.Viz.Web/Services/ScenarioService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ public ScenarioService(IConfiguration configuration)
var entries = new List<(string Id, Vector3 Pos)>();
foreach (var entry in child.GetChildren())
{
var id = entry["id"] ?? string.Empty;
var id = entry["id"] ?? string.Empty;
var pos = entry.GetSection("pos").Get<float[]>() ?? Array.Empty<float>();
if (!string.IsNullOrEmpty(id) && pos.Length == 3)
entries.Add((id, new Vector3(pos[0], pos[1], pos[2])));
Expand Down
34 changes: 17 additions & 17 deletions src/ResQ.Viz.Web/Services/SimulationService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -76,13 +76,13 @@ public sealed class SimulationService : BackgroundService
/// <param name="logger">Logger instance.</param>
public SimulationService(IHubContext<VizHub> hubContext, VizFrameBuilder frameBuilder, ILogger<SimulationService> logger)
{
_hubContext = hubContext;
_hubContext = hubContext;
_frameBuilder = frameBuilder;
_logger = logger;
_terrain = new TerrainNoiseService();
_weather = new UpdatableWeatherSystem(new WeatherConfig());
_world = new SimulationWorld(new SimulationConfig(), _terrain, _weather);
_swarm = new SwarmController(_terrain);
_logger = logger;
_terrain = new TerrainNoiseService();
_weather = new UpdatableWeatherSystem(new WeatherConfig());
_world = new SimulationWorld(new SimulationConfig(), _terrain, _weather);
_swarm = new SwarmController(_terrain);
Comment on lines +82 to +85
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The constructor directly instantiates several complex dependencies (TerrainNoiseService, UpdatableWeatherSystem, SimulationWorld, and SwarmController). This violates the Dependency Inversion Principle and makes the service difficult to unit test in isolation. Consider registering these as services in the dependency injection container and injecting them through the constructor.

_logger.LogInformation("SimulationService initialised.");
}

Expand Down Expand Up @@ -124,9 +124,9 @@ public void SetWeather(string mode, double windSpeed, double direction)
{
var weatherMode = mode.ToLowerInvariant() switch
{
"steady" => WeatherMode.Steady,
"steady" => WeatherMode.Steady,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The mode parameter is used with ToLowerInvariant() on line 125 (context). If mode is null (which can happen if it is missing from the API request body), this will throw a NullReferenceException. Consider adding a null check or using a null-safe approach before calling ToLowerInvariant().

"turbulent" => WeatherMode.Turbulent,
_ => WeatherMode.Calm,
_ => WeatherMode.Calm,
};
_weather.Update(new WeatherConfig(weatherMode, direction, windSpeed));
_logger.LogInformation("Weather updated: mode={Mode}, speed={Speed} m/s, direction={Dir}°.", weatherMode, windSpeed, direction);
Expand Down Expand Up @@ -161,10 +161,10 @@ public void Reset()
{
lock (_lock)
{
_world = new SimulationWorld(new SimulationConfig(), _terrain, _weather);
_simTime = 0;
_tickCount = 0;
_swarmTick = 0;
_world = new SimulationWorld(new SimulationConfig(), _terrain, _weather);
_simTime = 0;
_tickCount = 0;
_swarmTick = 0;
_logger.LogInformation("Simulation reset.");
}
}
Expand All @@ -181,13 +181,13 @@ public IReadOnlyList<DroneSnapshot> GetSnapshot()
var q = state.Orientation;

return new DroneSnapshot(
Id: d.Id,
Id: d.Id,
Position: [state.Position.X, state.Position.Y, state.Position.Z],
Rotation: [q.X, q.Y, q.Z, q.W],
Velocity: [state.Velocity.X, state.Velocity.Y, state.Velocity.Z],
Battery: state.BatteryPercent,
Status: d.FlightModel.HasLanded ? "landed" : "flying",
Armed: !d.FlightModel.HasLanded);
Battery: state.BatteryPercent,
Status: d.FlightModel.HasLanded ? "landed" : "flying",
Armed: !d.FlightModel.HasLanded);
}).ToList();
}
}
Expand Down Expand Up @@ -224,7 +224,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)

// Build and broadcast frame outside the lock to avoid holding it during async I/O.
var snapshot = GetSnapshot();
var frame = _frameBuilder.Build(snapshot, _simTime);
var frame = _frameBuilder.Build(snapshot, _simTime);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

The variable _simTime is accessed here without a lock, but it is modified under a lock in the background loop (line 215) and in the Reset method (line 165). Since double operations are not guaranteed to be atomic in C#, this can lead to torn reads or inconsistent values when the simulation is reset or updated from another thread. It is safer to capture the current simulation time while holding the lock in the main loop.

try
{
await _hubContext.Clients.All.SendAsync("ReceiveFrame", frame, stoppingToken);
Expand Down
28 changes: 14 additions & 14 deletions src/ResQ.Viz.Web/Services/SwarmController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -77,14 +77,14 @@ public DroneRole(Vector3[] route, int routeIndex, double assignedAt, bool retiri
public void SetTerrainPreset(string preset, TerrainNoiseService terrain, IReadOnlyList<SimulatedDrone> drones)
{
_terrain = terrain;
_preset = preset.ToLowerInvariant();
_minAgl = _preset switch
_preset = preset.ToLowerInvariant();
_minAgl = _preset switch
{
"ridgeline" => 20f,
"coastal" => 15f,
"canyon" => 12f,
"dunes" => 8f,
_ => 25f, // alpine default
"coastal" => 15f,
"canyon" => 12f,
"dunes" => 8f,
_ => 25f, // alpine default
};

// Rebuild routes for all current drones under the new terrain
Expand Down Expand Up @@ -214,9 +214,9 @@ private Vector3[] BuildRoute(int droneIndex, int totalDrones, Vector3 spawnPos)
{
return _scenario switch
{
"single" => BuildLawnmowerRoute(0, 0, 500f, 200f),
"sar" => BuildSarSectorRoute(droneIndex, totalDrones),
_ => BuildSectorPatrolRoute(droneIndex, totalDrones),
"single" => BuildLawnmowerRoute(0, 0, 500f, 200f),
"sar" => BuildSarSectorRoute(droneIndex, totalDrones),
_ => BuildSectorPatrolRoute(droneIndex, totalDrones),
};
}

Expand Down Expand Up @@ -256,11 +256,11 @@ private Vector3[] BuildSectorPatrolRoute(int idx, int total)
// Terrain-specific route shape
return _preset switch
{
"coastal" => BuildIslandPatrolRoute(cx, cz, Math.Min(cellW, cellH) * 0.38f),
"canyon" => BuildCanyonCorridor(idx, total),
"dunes" => BuildDuneSweep(cx, cz, cellW * 0.4f, cellH * 0.4f),
"coastal" => BuildIslandPatrolRoute(cx, cz, Math.Min(cellW, cellH) * 0.38f),
"canyon" => BuildCanyonCorridor(idx, total),
"dunes" => BuildDuneSweep(cx, cz, cellW * 0.4f, cellH * 0.4f),
"ridgeline" => BuildRidgelineRoute(cx, cz, cellW * 0.45f),
_ => BuildOctagonRoute(cx, cz, Math.Min(cellW, cellH) * 0.40f),
_ => BuildOctagonRoute(cx, cz, Math.Min(cellW, cellH) * 0.40f),
};
}

Expand All @@ -271,7 +271,7 @@ private Vector3[] BuildSarSectorRoute(int idx, int total)
total = Math.Max(total, 1);
float stripW = 2000f / total;
float stripCx = -1000f + (idx + 0.5f) * stripW;
float halfW = stripW * 0.45f;
float halfW = stripW * 0.45f;
return BuildLawnmowerRoute(stripCx, 0f, halfW, Math.Max(halfW * 0.4f, 60f));
}

Expand Down
56 changes: 28 additions & 28 deletions src/ResQ.Viz.Web/Services/TerrainNoiseService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,10 @@ public void SetPreset(string key) =>
public double GetElevation(double x, double z) => _preset switch
{
"ridgeline" => RidgelineHeight(x, z),
"coastal" => CoastalHeight(x, z),
"canyon" => CanyonHeight(x, z),
"dunes" => DuneHeight(x, z),
_ => AlpineHeight(x, z),
"coastal" => CoastalHeight(x, z),
"canyon" => CanyonHeight(x, z),
"dunes" => DuneHeight(x, z),
_ => AlpineHeight(x, z),
};

/// <inheritdoc/>
Expand All @@ -61,10 +61,10 @@ private static double Noise(double x, double z)
double fx = x - ix, fz = z - iz;
double ux = fx * fx * fx * (fx * (fx * 6 - 15) + 10);
double uz = fz * fz * fz * (fz * (fz * 6 - 15) + 10);
return H(ix, iz) * (1 - ux) * (1 - uz)
+ H(ix+1, iz) * ux * (1 - uz)
+ H(ix, iz+1) * (1 - ux) * uz
+ H(ix+1, iz+1) * ux * uz;
return H(ix, iz) * (1 - ux) * (1 - uz)
+ H(ix + 1, iz) * ux * (1 - uz)
+ H(ix, iz + 1) * (1 - ux) * uz
+ H(ix + 1, iz + 1) * ux * uz;
}

private static double Fbm(double x, double z, int octaves)
Expand All @@ -84,12 +84,12 @@ private static double Ridged(double x, double z, int octaves,
double value = 0, weight = 1;
for (int i = 0; i < octaves; i++)
{
double freq = Math.Pow(lacunarity, i);
double n = Noise(x * freq, z * freq);
double freq = Math.Pow(lacunarity, i);
double n = Noise(x * freq, z * freq);
double signal = 1 - Math.Abs(n * 2 - 1);
double s2 = signal * signal * weight;
value += s2;
weight = Math.Min(signal * gain, 1.0);
double s2 = signal * signal * weight;
value += s2;
weight = Math.Min(signal * gain, 1.0);
}
return value / octaves;
}
Expand All @@ -110,9 +110,9 @@ private static double AlpineHeight(double x, double z)
double wx = (Fbm(x * freq + 0.0, z * freq + 0.0, 3) * 2 - 1) * 260;
double wz = (Fbm(x * freq + 5.2, z * freq + 1.3, 3) * 2 - 1) * 260;

double large = (Fbm((x + wx) * 0.00055, (z + wz) * 0.00055, 6) * 2 - 1) * 46;
double medium = (Fbm(x * 0.0028 + 4.1, z * 0.0028 + 8.6, 4) * 2 - 1) * 16;
double fine = (Fbm(x * 0.013 + 2.2, z * 0.013 + 5.9, 3) * 2 - 1) * 3;
double large = (Fbm((x + wx) * 0.00055, (z + wz) * 0.00055, 6) * 2 - 1) * 46;
double medium = (Fbm(x * 0.0028 + 4.1, z * 0.0028 + 8.6, 4) * 2 - 1) * 16;
double fine = (Fbm(x * 0.013 + 2.2, z * 0.013 + 5.9, 3) * 2 - 1) * 3;

double peaks = 0;
foreach (var (px, pz, ph, pr) in AlpinePeaks)
Expand All @@ -129,7 +129,7 @@ private static double RidgelineHeight(double x, double z)
{
double ridge = Ridged(x * 0.00075 + 1.1, z * 0.00075 + 0.8, 8) * 195;
double baseH = (Fbm(x * 0.0022 + 3.1, z * 0.0022 + 7.4, 4) * 2 - 1) * 22;
double fine = (Fbm(x * 0.011 + 2.2, z * 0.011 + 5.9, 3) * 2 - 1) * 4;
double fine = (Fbm(x * 0.011 + 2.2, z * 0.011 + 5.9, 3) * 2 - 1) * 4;
return 8 + ridge + baseH + fine;
}

Expand All @@ -153,8 +153,8 @@ private static double CoastalHeight(double x, double z)
if (t > 0) mask = Math.Max(mask, t);
}
double perturbN = (Fbm(x * 0.005 + 2.1, z * 0.005 + 0.7, 4) * 2 - 1) * 0.28;
double m = Math.Max(0, mask + perturbN);
double topo = (Fbm(x * 0.0040 + 1.3, z * 0.0040 + 5.2, 5) * 2 - 1) * 62;
double m = Math.Max(0, mask + perturbN);
double topo = (Fbm(x * 0.0040 + 1.3, z * 0.0040 + 5.2, 5) * 2 - 1) * 62;
return topo * Math.Pow(m, 1.3) - 4;
}

Expand All @@ -164,12 +164,12 @@ private static double CanyonHeight(double x, double z)
{
double baseH = (Fbm(x * 0.00095 + 1.3, z * 0.00095 + 2.7, 5) * 2 - 1) * 28 + 55;
const double T = 20;
double frac = (((baseH % T) + T) % T) / T;
double step = Math.Min(frac / 0.18, 1.0);
double sf = step * step * (3 - 2 * step);
double frac = (((baseH % T) + T) % T) / T;
double step = Math.Min(frac / 0.18, 1.0);
double sf = step * step * (3 - 2 * step);
double terraced = baseH - frac * T + sf * T;
double canyonN = Fbm(x * 0.0048 + 7.1, z * 0.0038 + 3.4, 4);
double depth = canyonN < 0.32 ? Math.Pow(1 - canyonN / 0.32, 2) * 80 : 0;
double canyonN = Fbm(x * 0.0048 + 7.1, z * 0.0038 + 3.4, 4);
double depth = canyonN < 0.32 ? Math.Pow(1 - canyonN / 0.32, 2) * 80 : 0;
return terraced - depth;
}

Expand All @@ -178,12 +178,12 @@ private static double CanyonHeight(double x, double z)
private static double DuneHeight(double x, double z)
{
double d1n = Noise(x * 0.0028 + 0.0, z * 0.0145 + 0.0);
double d1 = Math.Pow(1 - Math.Abs(d1n * 2 - 1), 2.8) * 28;
double d1 = Math.Pow(1 - Math.Abs(d1n * 2 - 1), 2.8) * 28;
double ang = Math.PI * 0.15;
double cx = x * Math.Cos(ang) + z * Math.Sin(ang);
double cz = -x * Math.Sin(ang) + z * Math.Cos(ang);
double cx = x * Math.Cos(ang) + z * Math.Sin(ang);
double cz = -x * Math.Sin(ang) + z * Math.Cos(ang);
double d2n = Noise(cx * 0.0038 + 5.2, cz * 0.018 + 2.1);
double d2 = Math.Pow(1 - Math.Abs(d2n * 2 - 1), 2.2) * 14;
double d2 = Math.Pow(1 - Math.Abs(d2n * 2 - 1), 2.2) * 14;
double baseH = (Fbm(x * 0.0010, z * 0.0010, 4) * 2 - 1) * 14;
double field = Noise(x * 0.0018 + 1.7, z * 0.0018 + 3.3);
return 4 + baseH + d1 * (0.5 + field * 0.5) + d2;
Expand Down
36 changes: 18 additions & 18 deletions src/ResQ.Viz.Web/Services/VizFrameBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,16 +27,16 @@ public sealed class VizFrameBuilder

private sealed record SurvivorTargetConfig
{
public string Id { get; init; } = "";
public string Id { get; init; } = "";
public float[] Pos { get; init; } = [];
}

private sealed record HazardZoneConfig
{
public string Id { get; init; } = "";
public string Type { get; init; } = "";
public string Id { get; init; } = "";
public string Type { get; init; } = "";
public float[] Center { get; init; } = [];
public float Radius { get; init; }
public float Radius { get; init; }
}

private sealed record SurvivorTarget(string Id, Vector3 Position);
Expand Down Expand Up @@ -76,8 +76,8 @@ public VizFrameBuilder(IConfiguration configuration)
/// </summary>
public VizFrameBuilder()
{
_survivors = [];
_hazards = [];
_survivors = [];
_hazards = [];
_detectionRange = 35f;
}

Expand All @@ -94,11 +94,11 @@ public VizFrame Build(IReadOnlyList<DroneSnapshot> drones, double simTime)
.ToList();

return new VizFrame(
Time: simTime,
Drones: droneStates,
Time: simTime,
Drones: droneStates,
Detections: BuildDetections(drones),
Hazards: BuildHazards(),
Mesh: null);
Hazards: BuildHazards(),
Mesh: null);
}

// ── Private helpers ────────────────────────────────────────────────────────
Expand All @@ -116,10 +116,10 @@ private IReadOnlyList<DetectionVizState> BuildDetections(IReadOnlyList<DroneSnap
if (dist <= _detectionRange)
{
detections.Add(new DetectionVizState(
Id: target.Id,
Type: "survivor",
Pos: [target.Position.X, target.Position.Y, target.Position.Z],
DroneId: drone.Id,
Id: target.Id,
Type: "survivor",
Pos: [target.Position.X, target.Position.Y, target.Position.Z],
DroneId: drone.Id,
Confidence: 1f - dist / _detectionRange));
}
}
Expand All @@ -129,9 +129,9 @@ private IReadOnlyList<DetectionVizState> BuildDetections(IReadOnlyList<DroneSnap

private IReadOnlyList<HazardVizState> BuildHazards() =>
_hazards.Select(h => new HazardVizState(
Id: h.Id,
Type: h.Type,
Center: h.Center.Length == 3 ? [h.Center[0], h.Center[1], h.Center[2]] : [0f, 0f, 0f],
Radius: h.Radius,
Id: h.Id,
Type: h.Type,
Center: h.Center.Length == 3 ? [h.Center[0], h.Center[1], h.Center[2]] : [0f, 0f, 0f],
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

If h.Center is null (which can happen if the configuration binder encounters a null value in the JSON), accessing h.Center.Length will throw a NullReferenceException. Using a pattern match or null-conditional operator would be safer.

            Center: h.Center is { Length: 3 } ? [h.Center[0], h.Center[1], h.Center[2]] : [0f, 0f, 0f],

Radius: h.Radius,
Severity: "medium")).ToList();
}
Loading
Loading