railroader-setons-special-s.../src/Modules/CarCards/WaypointCutSim.cs
seton cc552a0246 CarCards: fanned consist dock with notes, actions, and WQ cut dividers
Shows the selected cut as a fan of cards with waybill, notes, and
couple/handbrake/locate actions. WaypointQueue cuts appear as dividers
when that mod is present.
2026-09-11 15:19:23 -04:00

693 lines
22 KiB
C#

using System;
using System.Collections.Generic;
using HarmonyLib;
using Model;
using Model.Definition;
using Model.Ops;
using S3.Modules.Popout;
using Track;
using UnityEngine;
namespace S3.Modules.CarCards;
enum WaypointAction
{
Cut,
Drop,
Pickup,
Couple,
}
sealed class WaypointDivider
{
public int Number;
public Color Color;
public WaypointAction Action;
public string LeftId = "";
public string RightId = "";
/// <summary>
/// When true, LeftId is the end car and RightId is the next car inward.
/// The divider sits on the outer face of LeftId, away from RightId.
/// </summary>
public bool Outer;
public string WaypointId = "";
public bool HasPosition;
public Vector3 Position;
public Quaternion Rotation = Quaternion.identity;
public string Headline = "";
public string Detail = "";
}
static class WaypointCutSim
{
const float NearCoupleM = 250f;
public static void Fill(IReadOnlyList<CardViewModel> cards, List<WaypointDivider> into, out string signature)
{
into.Clear();
signature = "";
if (cards == null || cards.Count == 0) return;
if (!CarCardsModule.Settings.showWaypointCuts) return;
if (!WaypointQueueBridge.IsInstalled) return;
BaseLocomotive? loco = LocoWithQueue(cards);
if (loco == null) return;
if (!WaypointQueueBridge.TryGetSnapshot(loco.id, out var snaps) || snaps.Count == 0)
return;
Color color = MapWaypointSystem.ColorForLoco(loco.id);
var present = new HashSet<string>(StringComparer.Ordinal);
string seedId = "";
for (int i = 0; i < cards.Count; i++)
{
string id = cards[i].Id;
if (string.IsNullOrEmpty(id)) continue;
present.Add(id);
if (seedId.Length == 0) seedId = id;
}
if (present.Count == 0) return;
// Couple-walk order (A-end to B-end), not the camera fan. View reverse
// only affects where dividers are drawn, not which cars they sit between.
var remaining = IdsOf(seedId);
remaining.RemoveAll(id => !present.Contains(id));
if (remaining.Count == 0)
{
remaining = new List<string>(present.Count);
for (int i = 0; i < cards.Count; i++)
{
string id = cards[i].Id;
if (!string.IsNullOrEmpty(id))
remaining.Add(id);
}
}
var parked = new List<ParkedCut>();
var sig = new System.Text.StringBuilder(loco.id);
sig.Append(':').Append(snaps.Count);
if (remaining.Count > 0)
sig.Append(':').Append(remaining[0]).Append('-').Append(remaining[remaining.Count - 1]);
for (int w = 0; w < snaps.Count; w++)
{
var wp = snaps[w];
sig.Append('|').Append(wp.Number)
.Append(':').Append(wp.CouplingSearchMode)
.Append(':').Append(wp.UncouplingMode)
.Append(':').Append(wp.NumberOfCarsToCut)
.Append(':').Append(wp.CoupleToCarId);
if (!Apply(wp, remaining, parked, present, into, color))
break;
}
signature = sig.ToString();
}
static bool Apply(
WaypointQueueBridge.WqWaypointSnap wp,
List<string> remaining,
List<ParkedCut> parked,
HashSet<string> present,
List<WaypointDivider> into,
Color color)
{
if (remaining.Count == 0) return false;
bool coupling = WaypointQueueBridge.Coupling(wp);
string coupleId = CoupleId(wp);
if (coupling && string.IsNullOrEmpty(coupleId)
&& string.Equals(wp.CouplingSearchMode, "Nearest", StringComparison.OrdinalIgnoreCase))
{
if (!TryResolveNearest(wp, remaining, parked, out coupleId))
return false;
}
if (coupling && string.IsNullOrEmpty(coupleId)
&& string.Equals(wp.CouplingSearchMode, "SpecificCar", StringComparison.OrdinalIgnoreCase))
coupleId = wp.CouplingSearchResultCarId ?? "";
if (Is(wp.UncouplingMode, "ByDestinationArea")
|| Is(wp.UncouplingMode, "ByDestinationIndustry")
|| Is(wp.UncouplingMode, "ByDestinationTrack"))
return true;
if (coupling && wp.Pickup && Is(wp.UncouplingMode, "ByCount") && wp.NumberOfCarsToCut > 0)
{
if (string.IsNullOrEmpty(coupleId)) return false;
bool onTrain = remaining.Contains(coupleId);
if (!TryCoupleEnd(remaining, wp, coupleId, out _, out bool atStart))
return false;
if (!onTrain)
AddEndDivider(into, wp, color, WaypointAction.Pickup, remaining, atStart);
if (!TryMergeForeign(remaining, coupleId, atStart, wp.NumberOfCarsToCut, parked, wp))
return false;
return true;
}
if (coupling && wp.Dropoff && Is(wp.UncouplingMode, "ByCount") && wp.NumberOfCarsToCut > 0)
{
if (string.IsNullOrEmpty(coupleId)) return false;
if (!TryCoupleEnd(remaining, wp, coupleId, out _, out bool atStart))
return false;
if (!TryCutCount(remaining, wp.NumberOfCarsToCut, fromStart: atStart, parked, wp, into, color, WaypointAction.Drop, present))
return remaining.Count > 0;
return true;
}
if (coupling && Is(wp.UncouplingMode, "BySpecificCar"))
return true;
if (coupling)
{
if (string.IsNullOrEmpty(coupleId)) return false;
bool onTrain = remaining.Contains(coupleId);
if (!TryCoupleEnd(remaining, wp, coupleId, out _, out bool atStart))
return false;
if (!onTrain)
AddEndDivider(into, wp, color, WaypointAction.Couple, remaining, atStart);
if (!TryMergeForeign(remaining, coupleId, atStart, keep: -1, parked, wp))
return false;
return true;
}
if (Is(wp.UncouplingMode, "AllExceptLocomotives"))
{
CutAllExceptLocos(remaining, parked, wp, into, color, present);
return remaining.Count > 0;
}
if (Is(wp.UncouplingMode, "ByCount"))
{
bool fromStart = !TakeFromLast(remaining, wp);
TryCutCount(remaining, wp.NumberOfCarsToCut, fromStart, parked, wp, into, color, WaypointAction.Cut, present);
return remaining.Count > 0;
}
if (Is(wp.UncouplingMode, "BySpecificCar"))
{
string spec = wp.UncouplingSearchResultCarId ?? "";
if (string.IsNullOrEmpty(spec)) return true;
int idx = remaining.IndexOf(spec);
if (idx < 0) return true;
bool fromStart = !TakeFromLast(remaining, wp);
int n = fromStart ? idx + 1 : remaining.Count - idx;
TryCutCount(remaining, n, fromStart, parked, wp, into, color, WaypointAction.Cut, present);
return remaining.Count > 0;
}
return true;
}
static string CoupleId(WaypointQueueBridge.WqWaypointSnap wp)
{
if (!string.IsNullOrEmpty(wp.CoupleToCarId)) return wp.CoupleToCarId;
if (!string.IsNullOrEmpty(wp.CouplingSearchResultCarId)) return wp.CouplingSearchResultCarId;
return "";
}
static bool TryCutCount(
List<string> remaining,
int n,
bool fromStart,
List<ParkedCut> parked,
WaypointQueueBridge.WqWaypointSnap wp,
List<WaypointDivider> into,
Color color,
WaypointAction action,
HashSet<string> present)
{
if (n <= 0 || n >= remaining.Count) return false;
string left;
string right;
List<string> cut;
if (fromStart)
{
left = remaining[n - 1];
right = remaining[n];
cut = remaining.GetRange(0, n);
remaining.RemoveRange(0, n);
}
else
{
int keep = remaining.Count - n;
left = remaining[keep - 1];
right = remaining[keep];
cut = remaining.GetRange(keep, n);
remaining.RemoveRange(keep, n);
}
Park(parked, cut, wp);
if (present.Contains(left) || present.Contains(right))
{
into.Add(DividerFrom(wp, color, action, left, right));
}
return true;
}
static void CutAllExceptLocos(
List<string> remaining,
List<ParkedCut> parked,
WaypointQueueBridge.WqWaypointSnap wp,
List<WaypointDivider> into,
Color color,
HashSet<string> present)
{
var keep = new List<string>();
var cut = new List<string>();
for (int i = 0; i < remaining.Count; i++)
{
if (IsLocoType(remaining[i])) keep.Add(remaining[i]);
else cut.Add(remaining[i]);
}
if (cut.Count == 0) return;
for (int i = 0; i < remaining.Count - 1; i++)
{
bool a = IsLocoType(remaining[i]);
bool b = IsLocoType(remaining[i + 1]);
if (a == b) continue;
string left = remaining[i];
string right = remaining[i + 1];
if (!present.Contains(left) && !present.Contains(right)) continue;
into.Add(DividerFrom(wp, color, WaypointAction.Cut, left, right));
}
Park(parked, cut, wp);
remaining.Clear();
remaining.AddRange(keep);
}
static void AddEndDivider(
List<WaypointDivider> into,
WaypointQueueBridge.WqWaypointSnap wp,
Color color,
WaypointAction action,
List<string> remaining,
bool atStart)
{
if (remaining.Count == 0) return;
string end;
string inward;
if (atStart)
{
end = remaining[0];
inward = remaining.Count > 1 ? remaining[1] : "";
}
else
{
end = remaining[remaining.Count - 1];
inward = remaining.Count > 1 ? remaining[remaining.Count - 2] : "";
}
var d = DividerFrom(wp, color, action, end, inward);
d.Outer = true;
into.Add(d);
}
static WaypointDivider DividerFrom(
WaypointQueueBridge.WqWaypointSnap wp,
Color color,
WaypointAction action,
string leftId,
string rightId)
{
return new WaypointDivider
{
Number = wp.Number,
Color = color,
Action = action,
LeftId = leftId,
RightId = rightId,
WaypointId = wp.Id ?? "",
HasPosition = wp.HasPosition,
Position = wp.Position,
Rotation = wp.Rotation,
Headline = Headline(action, wp),
Detail = DetailLines(wp, action),
};
}
static string Headline(WaypointAction action, WaypointQueueBridge.WqWaypointSnap wp)
{
int n = wp.NumberOfCarsToCut;
return action switch
{
WaypointAction.Pickup => n > 0 ? $"Pickup {n}" : "Pickup",
WaypointAction.Drop => n > 0 ? $"Drop {n}" : "Drop",
WaypointAction.Couple => "Couple",
_ => n > 0 ? $"Cut {n}" : "Cut",
};
}
static string DetailLines(WaypointQueueBridge.WqWaypointSnap wp, WaypointAction action)
{
var lines = new List<string>();
if (!string.IsNullOrEmpty(wp.Name))
lines.Add(wp.Name);
string place = PlaceName(wp);
if (!string.IsNullOrEmpty(place))
lines.Add(place);
string couple = CarMark(wp.CoupleToCarId);
if (string.IsNullOrEmpty(couple))
couple = CarMark(wp.CouplingSearchResultCarId);
if (!string.IsNullOrEmpty(couple) && action != WaypointAction.Cut)
lines.Add("To " + couple);
if (action == WaypointAction.Cut && wp.NumberOfCarsToCut > 0)
lines.Add(wp.CountFromNearest ? "Nearest end" : "Furthest end");
if (wp.WillWait)
lines.Add(wp.WaitMinutes > 0 ? $"Wait {wp.WaitMinutes} min" : "Wait");
if (wp.WillRefuel)
lines.Add(string.IsNullOrEmpty(wp.RefuelLoad) ? "Refuel" : "Refuel " + wp.RefuelLoad);
if (!string.IsNullOrEmpty(wp.Notes))
lines.Add(wp.Notes);
return string.Join("\n", lines);
}
static string PlaceName(WaypointQueueBridge.WqWaypointSnap wp)
{
if (!string.IsNullOrEmpty(wp.AreaName))
return wp.AreaName;
if (!wp.HasPosition) return "";
try
{
var ops = OpsController.Shared;
if (ops == null) return "";
Area? area = ops.ClosestAreaForGamePosition(wp.Position);
return area != null ? area.name : "";
}
catch { return ""; }
}
static string CarMark(string id)
{
if (string.IsNullOrEmpty(id)) return "";
Car? c = ConsistBinder.Find(id);
if (c == null) return "";
try
{
return string.IsNullOrEmpty(c.DisplayName) ? id : c.DisplayName;
}
catch { return id; }
}
static bool TryCoupleEnd(
List<string> remaining,
WaypointQueueBridge.WqWaypointSnap wp,
string coupleId,
out string endId,
out bool atStart)
{
endId = "";
atStart = false;
if (remaining.Count == 0) return false;
int already = remaining.IndexOf(coupleId);
if (already >= 0)
{
atStart = already * 2 < remaining.Count;
endId = coupleId;
return true;
}
Vector3 hint = wp.HasPosition ? wp.Position : default;
if (TryGamePosId(coupleId, out Vector3 couplePos))
hint = couplePos;
else if (!wp.HasPosition)
return false;
atStart = !NearIsLast(remaining, hint);
endId = atStart ? remaining[0] : remaining[remaining.Count - 1];
return true;
}
static bool TryMergeForeign(
List<string> remaining,
string coupleId,
bool atStart,
int keep,
List<ParkedCut> parked,
WaypointQueueBridge.WqWaypointSnap wp)
{
if (remaining.Contains(coupleId)) return true;
var foreign = IdsOf(coupleId);
if (foreign.Count == 0) return false;
foreign.RemoveAll(remaining.Contains);
int ci = foreign.IndexOf(coupleId);
if (ci < 0)
{
foreign.Insert(0, coupleId);
ci = 0;
}
if (ci != 0 && ci != foreign.Count - 1)
return false;
if (ci == 0 && atStart) foreign.Reverse();
if (ci != 0 && !atStart) foreign.Reverse();
if (keep >= 0 && keep < foreign.Count)
{
List<string> extra;
List<string> take;
if (atStart)
{
int drop = foreign.Count - keep;
extra = foreign.GetRange(0, drop);
take = foreign.GetRange(drop, keep);
}
else
{
take = foreign.GetRange(0, keep);
extra = foreign.GetRange(keep, foreign.Count - keep);
}
if (extra.Count > 0) Park(parked, extra, wp);
foreign = take;
}
if (atStart) remaining.InsertRange(0, foreign);
else remaining.AddRange(foreign);
ForgetParked(parked, foreign);
return true;
}
static bool TryResolveNearest(
WaypointQueueBridge.WqWaypointSnap wp,
List<string> remaining,
List<ParkedCut> parked,
out string coupleId)
{
coupleId = "";
if (!wp.HasPosition) return false;
Vector3 wpPos = wp.Position;
var remainingSet = new HashSet<string>(remaining);
string bestParked = "";
float bestParkedD = NearCoupleM;
for (int i = 0; i < parked.Count; i++)
{
var group = parked[i];
if (!TryLiveGroup(group.Ids, remainingSet, out List<string> live, out float dist, wpPos))
continue;
if (dist >= bestParkedD) continue;
bestParkedD = dist;
bestParked = ClosestId(live, wpPos);
}
if (!string.IsNullOrEmpty(bestParked))
{
coupleId = bestParked;
return true;
}
if (TryPollNearby(wpPos, remainingSet, out string pollId))
{
coupleId = pollId;
return true;
}
return false;
}
static bool TryLiveGroup(
List<string> ids,
HashSet<string> remaining,
out List<string> live,
out float dist,
Vector3 wpPos)
{
live = new List<string>();
dist = float.MaxValue;
string seed = "";
for (int i = 0; i < ids.Count; i++)
{
if (remaining.Contains(ids[i])) continue;
if (ConsistBinder.Find(ids[i]) == null) continue;
seed = ids[i];
break;
}
if (string.IsNullOrEmpty(seed)) return false;
live = IdsOf(seed);
if (live.Count == 0) return false;
bool overlap = false;
for (int i = 0; i < ids.Count; i++)
{
if (live.Contains(ids[i])) { overlap = true; break; }
}
if (!overlap) return false;
string closest = ClosestId(live, wpPos);
if (!TryGamePosId(closest, out Vector3 p)) return false;
dist = Vector3.Distance(p, wpPos);
return dist < NearCoupleM;
}
static bool TryPollNearby(Vector3 wpPos, HashSet<string> remaining, out string id)
{
id = "";
TrainController? tc = null;
try { tc = TrainController.Shared; } catch { }
if (tc?.Cars == null) return false;
float best = NearCoupleM;
foreach (Car c in tc.Cars)
{
if (c == null || remaining.Contains(c.id)) continue;
bool a = false, b = false;
try { a = c[Car.LogicalEnd.A].IsCoupled; } catch { }
try { b = c[Car.LogicalEnd.B].IsCoupled; } catch { }
if (a && b) continue;
if (!TryGamePos(c, out Vector3 p)) continue;
float d = Vector3.Distance(p, wpPos);
if (d >= best) continue;
best = d;
id = c.id;
}
return !string.IsNullOrEmpty(id);
}
static void Park(List<ParkedCut> parked, List<string> ids, WaypointQueueBridge.WqWaypointSnap wp)
{
if (ids == null || ids.Count == 0) return;
var copy = new List<string>(ids);
Vector3 pos = wp.HasPosition ? wp.Position : default;
if (!wp.HasPosition)
{
for (int i = 0; i < copy.Count; i++)
{
if (TryGamePosId(copy[i], out pos)) break;
}
}
parked.Add(new ParkedCut { Ids = copy, Pos = pos });
}
static void ForgetParked(List<ParkedCut> parked, List<string> taken)
{
if (taken.Count == 0) return;
var set = new HashSet<string>(taken);
for (int i = parked.Count - 1; i >= 0; i--)
{
parked[i].Ids.RemoveAll(set.Contains);
if (parked[i].Ids.Count == 0) parked.RemoveAt(i);
}
}
static bool TakeFromLast(List<string> remaining, WaypointQueueBridge.WqWaypointSnap wp)
{
bool nearLast = NearIsLast(remaining, wp.HasPosition ? wp.Position : default);
return wp.CountFromNearest ? nearLast : !nearLast;
}
static bool NearIsLast(List<string> remaining, Vector3 pos)
{
if (remaining.Count < 2) return true;
if (!TryGamePosId(remaining[0], out Vector3 a)) return true;
if (!TryGamePosId(remaining[remaining.Count - 1], out Vector3 b)) return true;
return Vector3.Distance(b, pos) <= Vector3.Distance(a, pos);
}
static List<string> IdsOf(string seedId)
{
var ids = new List<string>();
Car? seed = ConsistBinder.Find(seedId);
if (seed == null) return ids;
try
{
foreach (Car c in seed.EnumerateCoupled())
if (c != null) ids.Add(c.id);
}
catch { }
if (ids.Count == 0) ids.Add(seedId);
return ids;
}
static string ClosestId(List<string> ids, Vector3 pos)
{
string best = ids[0];
float bestD = float.MaxValue;
for (int i = 0; i < ids.Count; i++)
{
if (!TryGamePosId(ids[i], out Vector3 p)) continue;
float d = Vector3.Distance(p, pos);
if (d >= bestD) continue;
bestD = d;
best = ids[i];
}
return best;
}
static bool TryGamePosId(string id, out Vector3 pos)
{
pos = default;
Car? c = ConsistBinder.Find(id);
return c != null && TryGamePos(c, out pos);
}
static bool TryGamePos(Car car, out Vector3 pos)
{
pos = default;
try
{
if (car == null || Graph.Shared == null) return false;
pos = Graph.Shared.GetPosition(car.WheelBoundsA);
return true;
}
catch { return false; }
}
static bool IsLocoType(string id)
{
Car? c = ConsistBinder.Find(id);
if (c == null) return false;
try
{
if (c.IsLocomotive) return true;
var a = c.Archetype;
return a == CarArchetype.LocomotiveDiesel
|| a == CarArchetype.LocomotiveSteam
|| a == CarArchetype.Tender;
}
catch { return c is BaseLocomotive; }
}
static BaseLocomotive? LocoWithQueue(IReadOnlyList<CardViewModel> cards)
{
BaseLocomotive? lead = null;
BaseLocomotive? withQ = null;
for (int i = 0; i < cards.Count; i++)
{
if (cards[i].Car is not BaseLocomotive loco) continue;
lead ??= loco;
if (!WaypointQueueBridge.TryGetSnapshot(loco.id, out var snaps) || snaps.Count == 0)
continue;
bool mu = false;
try { mu = Traverse.Create(loco).Property<bool>("IsMuEnabled").Value; }
catch { }
if (!mu) return loco;
withQ ??= loco;
}
return withQ ?? lead;
}
static bool Is(string value, string name) =>
string.Equals(value, name, StringComparison.OrdinalIgnoreCase);
sealed class ParkedCut
{
public List<string> Ids = new();
public Vector3 Pos;
}
}