From d69d306736e1c37ecf43c3f20c7e5e5e6ffe13b4 Mon Sep 17 00:00:00 2001 From: Mike Odnis Date: Mon, 20 Apr 2026 02:06:59 -0400 Subject: [PATCH 1/2] feat(scenario): multi-agency-sar scenario with vendor-tinted chassis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First slice of the multi-agency coordination demo (#1 in the polish plan). Adds end-to-end vendor tagging — scenario config → simulation service → viz frame → client rendering — and a new `multi-agency-sar` preset with 12 drones across 3 vendors (skydio / autel / anzu, 4 each). Changes: * `Models/VizFrame.cs` — optional `Vendor` on `DroneVizState` * `Services/SimulationService.cs` — new `AddDrone(id, pos, vendor)` overload; sidecar `_droneVendors` dict cleared on `Reset()`; `DroneSnapshot.Vendor` populated from dict in `GetSnapshot()` * `Services/VizFrameBuilder.cs` — propagate vendor to frame * `Services/ScenarioService.cs` — public `Entry(Id, Pos, Vendor?)` record struct; parse `vendor` from appsettings * `appsettings.json` — `multi-agency-sar` scenario with 12 entries * `client/types.ts` — `DroneState.vendor?: string` * `client/drones.ts` — `VENDOR_COLORS` map + `bodyColor` param on `_buildQuadrotor`; top-plate tinted by vendor (subtle, silhouette preserved: skydio steel-blue, autel oxblood, anzu forest) Deferred to follow-ups (kept scope tight): * Kill-comms REST endpoint + `Mesh.Partitioned` signal * Partition status banner (client) * Scenario intro overlay (HURRICANE MELISSA toast) Verified: tsc clean, vite build green, `dotnet build -c Release` green, `dotnet format --verify-no-changes` clean, 82 / 82 tests passing. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/ResQ.Viz.Web/Models/VizFrame.cs | 9 ++++++- src/ResQ.Viz.Web/Services/ScenarioService.cs | 16 +++++++----- .../Services/SimulationService.cs | 26 ++++++++++++++++--- src/ResQ.Viz.Web/Services/VizFrameBuilder.cs | 2 +- src/ResQ.Viz.Web/appsettings.json | 14 ++++++++++ src/ResQ.Viz.Web/client/drones.ts | 18 ++++++++++--- src/ResQ.Viz.Web/client/types.ts | 6 +++++ 7 files changed, 76 insertions(+), 15 deletions(-) diff --git a/src/ResQ.Viz.Web/Models/VizFrame.cs b/src/ResQ.Viz.Web/Models/VizFrame.cs index ab861ea..d0cd360 100644 --- a/src/ResQ.Viz.Web/Models/VizFrame.cs +++ b/src/ResQ.Viz.Web/Models/VizFrame.cs @@ -25,6 +25,12 @@ public record VizFrame( MeshVizState? Mesh); /// Per-drone visual state in a VizFrame. +/// +/// tags the drone with an integrating agency's +/// equipment maker (e.g. skydio, autel, anzu) so the +/// client can render vendor-specific chassis tints in multi-agency scenarios. +/// Null for scenarios that don't need vendor differentiation. +/// public record DroneVizState( string Id, float[] Pos, @@ -32,7 +38,8 @@ public record DroneVizState( float[] Vel, double Battery, string Status, - bool Armed); + bool Armed, + string? Vendor = null); /// A hazard zone (fire, flood, etc.). public record HazardVizState( diff --git a/src/ResQ.Viz.Web/Services/ScenarioService.cs b/src/ResQ.Viz.Web/Services/ScenarioService.cs index 254eccb..e5918c0 100644 --- a/src/ResQ.Viz.Web/Services/ScenarioService.cs +++ b/src/ResQ.Viz.Web/Services/ScenarioService.cs @@ -24,7 +24,10 @@ namespace ResQ.Viz.Web.Services; /// public sealed class ScenarioService { - private readonly IReadOnlyDictionary> _scenarios; + /// Per-drone scenario entry: launch position and optional vendor tag. + public readonly record struct Entry(string Id, Vector3 Pos, string? Vendor); + + private readonly IReadOnlyDictionary> _scenarios; /// /// Initialises the service and loads scenario presets from . @@ -32,17 +35,18 @@ public sealed class ScenarioService /// Application configuration containing the Scenarios section. public ScenarioService(IConfiguration configuration) { - var dict = new Dictionary>(StringComparer.OrdinalIgnoreCase); + var dict = new Dictionary>(StringComparer.OrdinalIgnoreCase); var section = configuration.GetSection("Scenarios"); foreach (var child in section.GetChildren()) { - var entries = new List<(string Id, Vector3 Pos)>(); + var entries = new List(); foreach (var entry in child.GetChildren()) { var id = entry["id"] ?? string.Empty; var pos = entry.GetSection("pos").Get() ?? Array.Empty(); + var vendor = entry["vendor"]; if (!string.IsNullOrEmpty(id) && pos.Length == 3) - entries.Add((id, new Vector3(pos[0], pos[1], pos[2]))); + entries.Add(new Entry(id, new Vector3(pos[0], pos[1], pos[2]), string.IsNullOrEmpty(vendor) ? null : vendor)); } if (entries.Count > 0) dict[child.Key] = entries; @@ -68,8 +72,8 @@ public bool TryRun(string name, SimulationService sim) if (!_scenarios.TryGetValue(name, out var drones)) return false; - foreach (var (id, pos) in drones) - sim.AddDrone(id, pos); + foreach (var entry in drones) + sim.AddDrone(entry.Id, entry.Pos, entry.Vendor); return true; } diff --git a/src/ResQ.Viz.Web/Services/SimulationService.cs b/src/ResQ.Viz.Web/Services/SimulationService.cs index bbfc79c..3358701 100644 --- a/src/ResQ.Viz.Web/Services/SimulationService.cs +++ b/src/ResQ.Viz.Web/Services/SimulationService.cs @@ -42,7 +42,8 @@ public record DroneSnapshot( float[] Velocity, double Battery, string Status, - bool Armed); + bool Armed, + string? Vendor = null); /// /// Background service that owns the and ticks it at ~60 Hz. @@ -62,6 +63,9 @@ public sealed class SimulationService : BackgroundService private int _swarmTick; private double _simTime; + // Per-drone metadata that isn't in the SDK's SimulatedDrone. Keyed by drone id. + private readonly Dictionary _droneVendors = new(StringComparer.Ordinal); + /// Broadcast a viz frame every N simulation ticks (60 Hz / 6 = 10 Hz). private const int BroadcastEveryNTicks = 6; @@ -89,12 +93,24 @@ public SimulationService(IHubContext hubContext, VizFrameBuilder frameBu /// Adds a drone to the simulation world at the specified start position. /// Unique drone identifier. /// World-space launch position. - public void AddDrone(string id, Vector3 position) + public void AddDrone(string id, Vector3 position) => AddDrone(id, position, vendor: null); + + /// + /// Adds a drone to the simulation world with an optional vendor tag. + /// + /// Unique drone identifier. + /// World-space launch position. + /// Optional integrating-agency vendor tag (e.g. skydio). + public void AddDrone(string id, Vector3 position, string? vendor) { lock (_lock) { _world.AddDrone(id, position); - _logger.LogInformation("Drone {DroneId} added at ({X}, {Y}, {Z}).", id, position.X, position.Y, position.Z); + if (!string.IsNullOrEmpty(vendor)) + { + _droneVendors[id] = vendor; + } + _logger.LogInformation("Drone {DroneId} added at ({X}, {Y}, {Z}) vendor={Vendor}.", id, position.X, position.Y, position.Z, vendor ?? "none"); } } @@ -165,6 +181,7 @@ public void Reset() _simTime = 0; _tickCount = 0; _swarmTick = 0; + _droneVendors.Clear(); _logger.LogInformation("Simulation reset."); } } @@ -187,7 +204,8 @@ public IReadOnlyList GetSnapshot() Velocity: [state.Velocity.X, state.Velocity.Y, state.Velocity.Z], Battery: state.BatteryPercent, Status: d.FlightModel.HasLanded ? "landed" : "flying", - Armed: !d.FlightModel.HasLanded); + Armed: !d.FlightModel.HasLanded, + Vendor: _droneVendors.TryGetValue(d.Id, out var v) ? v : null); }).ToList(); } } diff --git a/src/ResQ.Viz.Web/Services/VizFrameBuilder.cs b/src/ResQ.Viz.Web/Services/VizFrameBuilder.cs index 0c43a41..296a467 100644 --- a/src/ResQ.Viz.Web/Services/VizFrameBuilder.cs +++ b/src/ResQ.Viz.Web/Services/VizFrameBuilder.cs @@ -90,7 +90,7 @@ public VizFrameBuilder() public VizFrame Build(IReadOnlyList drones, double simTime) { var droneStates = drones - .Select(d => new DroneVizState(d.Id, d.Position, d.Rotation, d.Velocity, d.Battery, d.Status, d.Armed)) + .Select(d => new DroneVizState(d.Id, d.Position, d.Rotation, d.Velocity, d.Battery, d.Status, d.Armed, d.Vendor)) .ToList(); return new VizFrame( diff --git a/src/ResQ.Viz.Web/appsettings.json b/src/ResQ.Viz.Web/appsettings.json index ac213f2..e2207cc 100644 --- a/src/ResQ.Viz.Web/appsettings.json +++ b/src/ResQ.Viz.Web/appsettings.json @@ -61,6 +61,20 @@ { "id": "sar-lead", "pos": [ 0.0, 20.0, 0.0] }, { "id": "sar-scout", "pos": [ 30.0, 25.0, 30.0] }, { "id": "sar-relay", "pos": [-30.0, 18.0, -30.0] } + ], + "multi-agency-sar": [ + { "id": "skydio-1", "pos": [-80.0, 20.0, -60.0], "vendor": "skydio" }, + { "id": "skydio-2", "pos": [-60.0, 22.0, -40.0], "vendor": "skydio" }, + { "id": "skydio-3", "pos": [-70.0, 18.0, -20.0], "vendor": "skydio" }, + { "id": "skydio-4", "pos": [-90.0, 24.0, -40.0], "vendor": "skydio" }, + { "id": "autel-1", "pos": [ 70.0, 20.0, -60.0], "vendor": "autel" }, + { "id": "autel-2", "pos": [ 90.0, 22.0, -40.0], "vendor": "autel" }, + { "id": "autel-3", "pos": [ 80.0, 18.0, -20.0], "vendor": "autel" }, + { "id": "autel-4", "pos": [ 60.0, 24.0, -40.0], "vendor": "autel" }, + { "id": "anzu-1", "pos": [-10.0, 28.0, 40.0], "vendor": "anzu" }, + { "id": "anzu-2", "pos": [ 10.0, 26.0, 60.0], "vendor": "anzu" }, + { "id": "anzu-3", "pos": [ 30.0, 24.0, 40.0], "vendor": "anzu" }, + { "id": "anzu-4", "pos": [-30.0, 30.0, 50.0], "vendor": "anzu" } ] } } diff --git a/src/ResQ.Viz.Web/client/drones.ts b/src/ResQ.Viz.Web/client/drones.ts index a4c2f60..48bd1cd 100644 --- a/src/ResQ.Viz.Web/client/drones.ts +++ b/src/ResQ.Viz.Web/client/drones.ts @@ -30,6 +30,17 @@ function lerpAlpha(dt: number): number { const BODY_COLOR = 0x161b22; const ARM_COLOR = 0x21262d; +/** + * Chassis top-plate tint per integrating-agency vendor. Subtle — keeps the + * silhouette consistent while giving a visible agency signature in + * multi-agency scenarios. Unmapped/absent vendor falls back to BODY_COLOR. + */ +const VENDOR_COLORS: Record = { + skydio: 0x2b3a55, // cool steel-blue + autel: 0x5a2a30, // deep oxblood + anzu: 0x2a4a36, // dark forest +}; + /** Detection range in world metres — matches appsettings DetectionRangeMeters. */ const DETECTION_RANGE_M = 35; @@ -222,7 +233,8 @@ export class DroneManager { private _add(d: DroneState): void { const color = STATUS_COLORS[d.status ?? ''] ?? DEFAULT_COLOR; - const { group, led, ring, rotors, label } = this._buildQuadrotor(color, d.id); + const bodyColor = d.vendor ? (VENDOR_COLORS[d.vendor] ?? BODY_COLOR) : BODY_COLOR; + const { group, led, ring, rotors, label } = this._buildQuadrotor(color, d.id, bodyColor); const startPos = new THREE.Vector3(d.pos[0], d.pos[1], d.pos[2]); group.position.copy(startPos); @@ -258,13 +270,13 @@ export class DroneManager { this._drones.set(d.id, entry); } - private _buildQuadrotor(statusColor: number, droneId: string): QuadrotorMesh { + private _buildQuadrotor(statusColor: number, droneId: string, bodyColor: number = BODY_COLOR): QuadrotorMesh { const group = new THREE.Group(); // ── Central body ────────────────────────────────────────────────────── const topPlate = new THREE.Mesh( new THREE.BoxGeometry(3.8, 0.35, 3.8), - new THREE.MeshStandardMaterial({ color: BODY_COLOR, metalness: 0.1, roughness: 0.75 }), + new THREE.MeshStandardMaterial({ color: bodyColor, metalness: 0.1, roughness: 0.75 }), ); topPlate.position.y = 0.3; topPlate.castShadow = true; diff --git a/src/ResQ.Viz.Web/client/types.ts b/src/ResQ.Viz.Web/client/types.ts index 3384e8c..f417481 100644 --- a/src/ResQ.Viz.Web/client/types.ts +++ b/src/ResQ.Viz.Web/client/types.ts @@ -15,6 +15,12 @@ export interface DroneState { status?: string; battery?: number; armed?: boolean; + /** + * Optional vendor tag identifying the integrating agency's equipment maker + * (e.g. "skydio", "autel", "anzu"). Used for chassis-tint differentiation + * in multi-agency scenarios. + */ + vendor?: string; } export function isDroneReady(d: DroneState | undefined): d is DroneState & { pos: [number,number,number]; rot: [number,number,number,number]; vel: [number,number,number] } { From 5518d6854b05b625180b8962ac5ac9a46dd0d46a Mon Sep 17 00:00:00 2001 From: Mike Odnis Date: Mon, 20 Apr 2026 02:21:40 -0400 Subject: [PATCH 2/2] fix(scenario): treat whitespace-only vendor as null Per review: `IsNullOrEmpty` accepts whitespace-only strings from appsettings (e.g. `"vendor": " "`) which would then become a dictionary key and flow into the viz frame. Swap to `IsNullOrWhiteSpace` so the parser is consistent with normal IConfiguration handling. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/ResQ.Viz.Web/Services/ScenarioService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ResQ.Viz.Web/Services/ScenarioService.cs b/src/ResQ.Viz.Web/Services/ScenarioService.cs index e5918c0..d4c3d8b 100644 --- a/src/ResQ.Viz.Web/Services/ScenarioService.cs +++ b/src/ResQ.Viz.Web/Services/ScenarioService.cs @@ -46,7 +46,7 @@ public ScenarioService(IConfiguration configuration) var pos = entry.GetSection("pos").Get() ?? Array.Empty(); var vendor = entry["vendor"]; if (!string.IsNullOrEmpty(id) && pos.Length == 3) - entries.Add(new Entry(id, new Vector3(pos[0], pos[1], pos[2]), string.IsNullOrEmpty(vendor) ? null : vendor)); + entries.Add(new Entry(id, new Vector3(pos[0], pos[1], pos[2]), string.IsNullOrWhiteSpace(vendor) ? null : vendor)); } if (entries.Count > 0) dict[child.Key] = entries;