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..d4c3d8b 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.IsNullOrWhiteSpace(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] } {