diff --git a/StarterNG.sln.DotSettings.user b/StarterNG.sln.DotSettings.user
index 6827f48..680ff6c 100644
--- a/StarterNG.sln.DotSettings.user
+++ b/StarterNG.sln.DotSettings.user
@@ -4,6 +4,7 @@
ForceIncluded
ForceIncluded
ForceIncluded
+ ForceIncluded
ForceIncluded
ForceIncluded
ForceIncluded
diff --git a/StarterNG/Assets/Langs/English.axaml b/StarterNG/Assets/Langs/English.axaml
index 2e8cce6..9b65a09 100644
--- a/StarterNG/Assets/Langs/English.axaml
+++ b/StarterNG/Assets/Langs/English.axaml
@@ -7,6 +7,38 @@
Consist
Start
+
+ Vehicle database
+ Search...
+ Load consist from scenery
+ New consist
+ Selected vehicle
+ Vehicle
+ with crew
+ reversed
+ No vehicles found in databases/vehicles/.
+ Add vehicles from the database on the left, or load an existing consist from a scenery file.
+ Move left
+ Move right
+ Reverse vehicle
+ Remove from consist
+ Change crew (cab 1 / cab 2 / passenger / none)
+ Scenery
+ Category
+ Class
+ All
+ Diesel
+ Electric
+ EMU
+ DMU
+ Carriages A
+ Carriages B
+ Brakes
+ Loads
+ Coupling
+ Select a vehicle in the consist to edit it.
+ Editing options coming soon.
+
General
diff --git a/StarterNG/Assets/Langs/Polski.axaml b/StarterNG/Assets/Langs/Polski.axaml
index dbc4dd5..290e810 100644
--- a/StarterNG/Assets/Langs/Polski.axaml
+++ b/StarterNG/Assets/Langs/Polski.axaml
@@ -6,7 +6,39 @@
Opis scenariusza
Zestawienie
Uruchom
-
+
+
+ Baza pojazdów
+ Szukaj...
+ Wczytaj skład ze scenerii
+ Nowy skład
+ Wybrany pojazd
+ Pojazd
+ z obsadą
+ obrócony
+ Nie znaleziono pojazdów w databases/vehicles/.
+ Dodaj pojazdy z bazy po lewej stronie lub wczytaj istniejący skład z pliku scenerii.
+ Przesuń w lewo
+ Przesuń w prawo
+ Obróć pojazd
+ Usuń ze składu
+ Zmień obsadę (kabina 1 / kabina 2 / pasażer / brak)
+ Sceneria
+ Kategoria
+ Klasa
+ Wszystkie
+ Spalinowe
+ Elektryczne
+ EZT
+ SZT
+ Wagony A
+ Wagony B
+ Hamulce
+ Ładunki
+ Sprzęg
+ Wybierz pojazd w składzie, aby go edytować.
+ Opcje edycji wkrótce.
+
Ogólne
diff --git a/StarterNG/Classes/Scenery.cs b/StarterNG/Classes/Scenery.cs
index dcb042d..7e30106 100644
--- a/StarterNG/Classes/Scenery.cs
+++ b/StarterNG/Classes/Scenery.cs
@@ -44,7 +44,15 @@ public class Scenery
foreach (string trainsetEntry in trainsetEntries)
{
- Trainsets.Add(new Trainset(trainsetEntry));
+ try
+ {
+ Trainsets.Add(new Trainset(trainsetEntry));
+ }
+ catch
+ {
+ // skip malformed trainset entries so one bad scenery
+ // doesn't break loading the rest
+ }
}
}
}
\ No newline at end of file
diff --git a/StarterNG/Classes/Trainset.cs b/StarterNG/Classes/Trainset.cs
index 12ab3dd..50b51d7 100644
--- a/StarterNG/Classes/Trainset.cs
+++ b/StarterNG/Classes/Trainset.cs
@@ -167,4 +167,30 @@ public class Dynamic
public eDriverType DriverType;
public byte couplingData;
public string Tail;
+
+ // --- depot / consist-builder extras (not part of the .scn syntax) ---
+
+ /// Explicit miniature name (from the vehicle database). Falls back to SkinFile.
+ public string? MiniName;
+
+ /// Whether the vehicle is visually reversed in the consist.
+ public bool Flipped;
+
+ /// Deep copy, used when importing a trainset into the editable consist.
+ public Dynamic Clone() => new Dynamic
+ {
+ RangeMax = RangeMax,
+ RangeMin = RangeMin,
+ Name = Name,
+ DataFolder = DataFolder,
+ SkinFile = SkinFile,
+ MmdFile = MmdFile,
+ PathName = PathName,
+ Offset = Offset,
+ DriverType = DriverType,
+ couplingData = couplingData,
+ Tail = Tail,
+ MiniName = MiniName,
+ Flipped = Flipped
+ };
}
\ No newline at end of file
diff --git a/StarterNG/Classes/VehicleDatabase.cs b/StarterNG/Classes/VehicleDatabase.cs
new file mode 100644
index 0000000..8127464
--- /dev/null
+++ b/StarterNG/Classes/VehicleDatabase.cs
@@ -0,0 +1,232 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace StarterNG.Classes;
+
+// =====================================================================
+// Vehicle JSON database (databases/vehicles/*.json)
+// Format documented in vehicleEntryDoc.md (schema_version 1).
+// =====================================================================
+
+/// Root object of a single vehicle JSON file.
+public class VehicleEntry
+{
+ [JsonPropertyName("uuid")] public string? Uuid { get; set; }
+ [JsonPropertyName("schema_version")] public int SchemaVersion { get; set; }
+ [JsonPropertyName("groups")] public List Groups { get; set; } = new();
+ [JsonPropertyName("textures")] public List Textures { get; set; } = new();
+ [JsonPropertyName("sets")] public List Sets { get; set; } = new();
+ [JsonPropertyName("unknown")] public List Unknown { get; set; } = new();
+}
+
+/// Merged database root (databases/vehicles/vehicles.json).
+public class VehicleEntryCollection
+{
+ [JsonPropertyName("vehicles")] public List Vehicles { get; set; } = new();
+}
+
+/// Legacy header / vehicle category group.
+public class VehicleGroup
+{
+ [JsonPropertyName("id")] public string Id { get; set; } = "";
+ [JsonPropertyName("category")] public string? Category { get; set; }
+ [JsonPropertyName("mini")] public string? Mini { get; set; }
+ [JsonPropertyName("archived")] public bool Archived { get; set; }
+ [JsonPropertyName("implicit")] public bool Implicit { get; set; }
+}
+
+/// A single .mat skin entry.
+public class VehicleTexture
+{
+ [JsonPropertyName("uuid")] public string? Uuid { get; set; }
+ [JsonPropertyName("directory")] public string Directory { get; set; } = "";
+ [JsonPropertyName("skinfile")] public string Skinfile { get; set; } = "";
+ [JsonPropertyName("model")] public string? Model { get; set; }
+ [JsonPropertyName("group")] public string? Group { get; set; }
+ [JsonPropertyName("mini_ref")] public string? MiniRef { get; set; }
+ [JsonPropertyName("texture_mini")] public string? TextureMini { get; set; }
+ [JsonPropertyName("wreck")] public bool Wreck { get; set; }
+ [JsonPropertyName("aliases")] public List Aliases { get; set; } = new();
+ [JsonPropertyName("meta")] public VehicleMeta? Meta { get; set; }
+
+ /// Full skin path = directory + skinfile.
+ [JsonIgnore] public string FullPath => Directory + Skinfile;
+}
+
+/// Alternate mapping from a malformed multi-= legacy line.
+public class VehicleAlias
+{
+ [JsonPropertyName("model")] public string? Model { get; set; }
+ [JsonPropertyName("group")] public string? Group { get; set; }
+ [JsonPropertyName("mini_ref")] public string? MiniRef { get; set; }
+ [JsonPropertyName("texture_mini")] public string? TextureMini { get; set; }
+}
+
+/// Parsed metadata from the legacy // comment section.
+public class VehicleMeta
+{
+ [JsonPropertyName("raw")] public string? Raw { get; set; }
+ [JsonPropertyName("version")] public string? Version { get; set; }
+ [JsonPropertyName("vehicle")] public string? Vehicle { get; set; }
+ [JsonPropertyName("operator")] public string? Operator { get; set; }
+ [JsonPropertyName("depot")] public string? Depot { get; set; }
+ [JsonPropertyName("revision_date")] public string? RevisionDate { get; set; }
+ [JsonPropertyName("revision_place")] public string? RevisionPlace { get; set; }
+ [JsonPropertyName("texture_author")] public string? TextureAuthor { get; set; }
+ [JsonPropertyName("photo_author")] public string? PhotoAuthor { get; set; }
+ [JsonPropertyName("extra")] public List Extra { get; set; } = new();
+}
+
+///
+/// Aggregated, in-memory view over every vehicle JSON file found in
+/// databases/vehicles/. Provides lookup helpers for the depot UI.
+///
+public class VehicleDatabase
+{
+ public List Textures { get; } = new();
+ public Dictionary GroupsById { get; } = new();
+ public List Sets { get; } = new();
+
+ /// Texture-uuid -> automatic-consist set that contains it.
+ public Dictionary SetByTextureUuid { get; } = new();
+
+ private static readonly JsonSerializerOptions JsonOpts = new()
+ {
+ PropertyNameCaseInsensitive = true,
+ AllowTrailingCommas = true,
+ ReadCommentHandling = JsonCommentHandling.Skip
+ };
+
+ ///
+ /// Loads the database. When a merged vehicles.json is present it is used,
+ /// otherwise every per-vehicle *.json file in the directory is read.
+ /// Never throws: unreadable files are skipped.
+ ///
+ public void Load(string directory = "databases/vehicles/")
+ {
+ Textures.Clear();
+ GroupsById.Clear();
+ Sets.Clear();
+ SetByTextureUuid.Clear();
+
+ if (!Directory.Exists(directory))
+ return;
+
+ string merged = Path.Combine(directory, "vehicles.json");
+ if (File.Exists(merged))
+ {
+ LoadMerged(merged);
+ }
+ else
+ {
+ foreach (string file in Directory.GetFiles(directory, "*.json"))
+ LoadEntryFile(file);
+ }
+
+ BuildSetIndex();
+ }
+
+ private void LoadMerged(string file)
+ {
+ try
+ {
+ var collection = JsonSerializer.Deserialize(
+ File.ReadAllText(file), JsonOpts);
+ if (collection?.Vehicles is null) return;
+ foreach (var entry in collection.Vehicles)
+ Ingest(entry);
+ }
+ catch
+ {
+ // ignore malformed merged database
+ }
+ }
+
+ private void LoadEntryFile(string file)
+ {
+ try
+ {
+ var entry = JsonSerializer.Deserialize(
+ File.ReadAllText(file), JsonOpts);
+ if (entry is not null)
+ Ingest(entry);
+ }
+ catch
+ {
+ // ignore malformed single-vehicle file
+ }
+ }
+
+ private void Ingest(VehicleEntry entry)
+ {
+ foreach (var group in entry.Groups)
+ {
+ if (!string.IsNullOrEmpty(group.Id))
+ GroupsById.TryAdd(group.Id, group);
+ }
+
+ foreach (var texture in entry.Textures)
+ {
+ // skip wrecks from the standard browser list
+ if (texture.Wreck) continue;
+ Textures.Add(texture);
+ }
+
+ Sets.AddRange(entry.Sets);
+ }
+
+ private void BuildSetIndex()
+ {
+ foreach (var set in Sets)
+ {
+ if (set.TextureRefs is null) continue;
+ foreach (string uuid in set.TextureRefs)
+ {
+ if (!string.IsNullOrEmpty(uuid))
+ SetByTextureUuid[uuid] = set;
+ }
+ }
+ }
+
+ /// Best miniature name for a texture (texture_mini -> mini_ref -> group mini).
+ public string? ResolveMiniName(VehicleTexture texture)
+ {
+ if (!string.IsNullOrEmpty(texture.TextureMini)) return texture.TextureMini;
+ if (!string.IsNullOrEmpty(texture.MiniRef)) return texture.MiniRef;
+ if (texture.Group is not null && GroupsById.TryGetValue(texture.Group, out var grp))
+ return grp.Mini;
+ return null;
+ }
+
+ /// Header label for the group a texture belongs to.
+ public string GroupHeader(string? groupId)
+ {
+ if (!string.IsNullOrEmpty(groupId) && GroupsById.TryGetValue(groupId, out var grp))
+ {
+ string mini = string.IsNullOrEmpty(grp.Mini) ? grp.Id : grp.Mini!;
+ return string.IsNullOrEmpty(grp.Category) ? mini : $"{mini} ({grp.Category})";
+ }
+ return groupId ?? "";
+ }
+
+ /// Resolves a miniature .bmp path under textures/mini/, or null if missing.
+ public static string? MiniPath(string? miniName, string miniDir = "textures/mini/")
+ {
+ if (string.IsNullOrEmpty(miniName)) return null;
+ string path = Path.Combine(miniDir, miniName + ".bmp");
+ return File.Exists(path) ? path : null;
+ }
+}
+
+/// Automatic consist definition (legacy ^x grouping).
+public class VehicleSet
+{
+ [JsonPropertyName("uuid")] public string? Uuid { get; set; }
+ [JsonPropertyName("mode")] public string? Mode { get; set; }
+ [JsonPropertyName("count")] public int Count { get; set; }
+ [JsonPropertyName("texture_refs")] public List TextureRefs { get; set; } = new();
+}
diff --git a/StarterNG/MainWindow.axaml b/StarterNG/MainWindow.axaml
index 0f52b74..e282b51 100644
--- a/StarterNG/MainWindow.axaml
+++ b/StarterNG/MainWindow.axaml
@@ -27,7 +27,7 @@
-
+
diff --git a/StarterNG/Views/Depot.axaml b/StarterNG/Views/Depot.axaml
new file mode 100644
index 0000000..33c1533
--- /dev/null
+++ b/StarterNG/Views/Depot.axaml
@@ -0,0 +1,95 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/StarterNG/Views/Depot.axaml.cs b/StarterNG/Views/Depot.axaml.cs
new file mode 100644
index 0000000..35485e3
--- /dev/null
+++ b/StarterNG/Views/Depot.axaml.cs
@@ -0,0 +1,713 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Avalonia.Media.Imaging;
+using StarterNG.Classes;
+
+namespace StarterNG.Views;
+
+public partial class Depot : UserControl
+{
+ private readonly VehicleDatabase _db = new();
+ private readonly List _sceneries = new();
+
+ // The consist currently being edited (belongs to the selected scenery).
+ private readonly List _consist = new();
+ private Dynamic? _selected;
+
+ private readonly Dictionary _miniCache = new();
+ private readonly Cursor _hand = new(StandardCursorType.Hand);
+ private int _nameCounter;
+
+ // suppresses combo SelectionChanged handlers while we populate them
+ private bool _suppress;
+
+ // active database filters
+ private string[]? _categoryFilter; // legacy category letters, null = all
+ private string? _classFilter; // group mini, null = all
+
+ // card highlight brushes (theme-neutral overlays)
+ private static readonly IBrush CardBorder = new SolidColorBrush(Color.Parse("#33888888"));
+ private static readonly IBrush CardBorderSel = new SolidColorBrush(Color.Parse("#AA4D8BFF"));
+ private static readonly IBrush CardBgSel = new SolidColorBrush(Color.Parse("#224D8BFF"));
+
+ // High-level category -> legacy category letters (from the JSON "category" field).
+ // NOTE: adjust these letters to match the actual database conventions.
+ private static readonly (string LocKey, string[] Letters)[] CategoryMap =
+ {
+ ("CatElectric", new[] { "e" }), // electric locomotives
+ ("CatDiesel", new[] { "s" }), // diesel locomotives
+ ("CatEMU", new[] { "z" }), // electric multiple units (EN57, ...)
+ ("CatDMU", new[] { "d" }), // diesel multiple units
+ ("CatCarriagesA", new[] { "o" }), // passenger carriages
+ ("CatCarriagesB", new[] { "t" }), // freight carriages
+ };
+
+ // Placeholder coupling-bit labels (couplingData byte). Finished later.
+ private static readonly string[] CouplingBits =
+ { "Coupler", "Brake pipe", "Brake tank", "Control", "Gangway", "High voltage", "Heating", "Aux" };
+
+ public Depot()
+ {
+ InitializeComponent();
+
+ if (Design.IsDesignMode)
+ return;
+
+ LoadDatabase();
+ LoadSceneries();
+
+ PopulateCategoryCombo(); // -> populates class combo -> builds browser
+ PopulateSceneryCombo(); // -> populates consist combo for the first scenery
+ RebuildConsist();
+ }
+
+ // ----------------------------------------------------------------- loading
+
+ private void LoadDatabase()
+ {
+ try { _db.Load("databases/vehicles/"); }
+ catch { /* leave database empty on failure */ }
+ }
+
+ private void LoadSceneries()
+ {
+ try
+ {
+ if (!Directory.Exists("scenery/"))
+ return;
+
+ foreach (string scn in Directory.GetFiles("scenery/", "*.scn"))
+ {
+ if (Path.GetFileName(scn).StartsWith("$"))
+ continue;
+ try { _sceneries.Add(new Scenery(scn)); }
+ catch { /* skip unreadable scenery */ }
+ }
+ }
+ catch { /* no scenery folder */ }
+ }
+
+ // ------------------------------------------------------------ category combo
+
+ private void PopulateCategoryCombo()
+ {
+ _suppress = true;
+ categoryCombo.Items.Clear();
+ categoryCombo.Items.Add(new ComboBoxItem { Content = App.Loc["All"], Tag = null });
+ foreach (var (locKey, letters) in CategoryMap)
+ categoryCombo.Items.Add(new ComboBoxItem { Content = App.Loc[locKey], Tag = letters });
+ categoryCombo.SelectedIndex = 0;
+ _suppress = false;
+
+ _categoryFilter = null;
+ RebuildClassCombo();
+ }
+
+ private void RebuildClassCombo()
+ {
+ _suppress = true;
+ classCombo.Items.Clear();
+ classCombo.Items.Add(new ComboBoxItem { Content = App.Loc["All"], Tag = null });
+
+ foreach (string mini in ClassesForCategory(_categoryFilter))
+ classCombo.Items.Add(new ComboBoxItem { Content = mini, Tag = mini });
+
+ classCombo.SelectedIndex = 0;
+ _suppress = false;
+
+ _classFilter = null;
+ BuildBrowser();
+ }
+
+ private IEnumerable ClassesForCategory(string[]? letters) =>
+ _db.GroupsById.Values
+ .Where(g => letters == null ||
+ (g.Category != null && letters.Contains(g.Category)))
+ .Select(g => g.Mini)
+ .Where(m => !string.IsNullOrEmpty(m))
+ .Select(m => m!)
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .OrderBy(m => m, StringComparer.OrdinalIgnoreCase);
+
+ private void CategoryCombo_OnSelectionChanged(object? sender, SelectionChangedEventArgs e)
+ {
+ if (_suppress) return;
+ _categoryFilter = (categoryCombo.SelectedItem as ComboBoxItem)?.Tag as string[];
+ RebuildClassCombo();
+ }
+
+ private void ClassCombo_OnSelectionChanged(object? sender, SelectionChangedEventArgs e)
+ {
+ if (_suppress) return;
+ _classFilter = (classCombo.SelectedItem as ComboBoxItem)?.Tag as string;
+ BuildBrowser();
+ }
+
+ // ----------------------------------------------------------- vehicle browser
+
+ private void BuildBrowser()
+ {
+ browserStack.Children.Clear();
+
+ string search = searchBox.Text?.Trim() ?? "";
+ bool hasSearch = search.Length > 0;
+
+ var visible = _db.Textures.Where(t => PassesFilters(t, search, hasSearch)).ToList();
+
+ var grouped = visible
+ .GroupBy(t => t.Group ?? "")
+ .OrderBy(g => _db.GroupHeader(g.Key), StringComparer.OrdinalIgnoreCase);
+
+ foreach (var group in grouped)
+ {
+ var list = group.ToList();
+ string header = string.IsNullOrEmpty(group.Key)
+ ? App.Loc["Others"]
+ : _db.GroupHeader(group.Key);
+
+ var content = new StackPanel { Spacing = 2 };
+ var expander = new Expander
+ {
+ Header = $"{header} ({list.Count})",
+ IsExpanded = hasSearch || _classFilter != null,
+ HorizontalAlignment = HorizontalAlignment.Stretch
+ };
+ expander.Content = content;
+
+ bool populated = false;
+ void Populate()
+ {
+ if (populated) return;
+ populated = true;
+ foreach (var texture in list)
+ content.Children.Add(BuildBrowserItem(texture));
+ }
+
+ if (expander.IsExpanded)
+ Populate();
+ expander.Expanded += (_, _) => Populate();
+
+ browserStack.Children.Add(expander);
+ }
+
+ if (browserStack.Children.Count == 0)
+ {
+ browserStack.Children.Add(new TextBlock
+ {
+ Text = App.Loc["NoVehicles"],
+ Opacity = 0.6,
+ TextWrapping = TextWrapping.Wrap,
+ Margin = new Thickness(4)
+ });
+ }
+ }
+
+ private bool PassesFilters(VehicleTexture t, string search, bool hasSearch)
+ {
+ if (_categoryFilter != null)
+ {
+ string? cat = GroupCategory(t);
+ if (cat == null || !_categoryFilter.Contains(cat))
+ return false;
+ }
+
+ if (_classFilter != null)
+ {
+ string? mini = GroupMini(t);
+ if (!string.Equals(mini, _classFilter, StringComparison.OrdinalIgnoreCase))
+ return false;
+ }
+
+ if (hasSearch && !Matches(t, search))
+ return false;
+
+ return true;
+ }
+
+ private string? GroupCategory(VehicleTexture t) =>
+ t.Group != null && _db.GroupsById.TryGetValue(t.Group, out var g) ? g.Category : null;
+
+ private string? GroupMini(VehicleTexture t) =>
+ t.Group != null && _db.GroupsById.TryGetValue(t.Group, out var g) ? g.Mini : null;
+
+ private bool Matches(VehicleTexture t, string f)
+ {
+ bool C(string? s) => !string.IsNullOrEmpty(s) &&
+ s.Contains(f, StringComparison.OrdinalIgnoreCase);
+ return C(t.Skinfile) || C(t.Model) || C(t.TextureMini) || C(t.MiniRef)
+ || C(t.Meta?.Vehicle) || C(t.Meta?.Operator) || C(_db.GroupHeader(t.Group));
+ }
+
+ private Control BuildBrowserItem(VehicleTexture texture)
+ {
+ var img = new Image { Height = 40, Width = 66, Stretch = Stretch.Uniform };
+ var bmp = GetMiniBitmap(_db.ResolveMiniName(texture));
+ if (bmp != null) img.Source = bmp;
+
+ var label = new TextBlock
+ {
+ Text = BrowserLabel(texture),
+ VerticalAlignment = VerticalAlignment.Center,
+ FontSize = 12,
+ TextWrapping = TextWrapping.Wrap
+ };
+
+ var stack = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8 };
+ stack.Children.Add(img);
+ stack.Children.Add(label);
+
+ var button = new Button
+ {
+ Content = stack,
+ HorizontalAlignment = HorizontalAlignment.Stretch,
+ HorizontalContentAlignment = HorizontalAlignment.Left,
+ Cursor = _hand
+ };
+ button.Classes.Add("Basic");
+ button.Click += (_, _) => AddFromTexture(texture);
+ ToolTip.SetTip(button, texture.FullPath);
+ return button;
+ }
+
+ private string BrowserLabel(VehicleTexture texture)
+ {
+ string name = !string.IsNullOrEmpty(texture.TextureMini)
+ ? texture.TextureMini!
+ : Path.GetFileNameWithoutExtension(texture.Skinfile);
+ if (!string.IsNullOrEmpty(texture.Meta?.Operator))
+ name += $" · {texture.Meta!.Operator}";
+ return name;
+ }
+
+ // ------------------------------------------------------------- scenery combos
+
+ private void PopulateSceneryCombo()
+ {
+ _suppress = true;
+ sceneryCombo.Items.Clear();
+ foreach (var scenery in _sceneries)
+ {
+ sceneryCombo.Items.Add(new ComboBoxItem
+ {
+ Content = Path.GetFileNameWithoutExtension(scenery.Path),
+ Tag = scenery
+ });
+ }
+ if (sceneryCombo.Items.Count > 0)
+ sceneryCombo.SelectedIndex = 0;
+ _suppress = false;
+
+ PopulateConsistCombo(_sceneries.FirstOrDefault());
+ }
+
+ private void PopulateConsistCombo(Scenery? scenery)
+ {
+ _suppress = true;
+ sceneryConsistCombo.Items.Clear();
+
+ if (scenery != null)
+ {
+ foreach (var trainset in scenery.Trainsets)
+ {
+ if (trainset.Vehicles.Count == 0)
+ continue;
+ if (trainset.Vehicles.All(v => string.IsNullOrWhiteSpace(v.Name)))
+ continue;
+
+ sceneryConsistCombo.Items.Add(new ComboBoxItem
+ {
+ Content = BuildConsistPreview(trainset),
+ Tag = trainset
+ });
+ }
+ }
+
+ sceneryConsistCombo.SelectedItem = null;
+ _suppress = false;
+ }
+
+ // Combo item: row of mini thumbnails on top, skin names underneath.
+ private Control BuildConsistPreview(Trainset trainset)
+ {
+ var minis = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 2 };
+ var names = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 6 };
+
+ foreach (var v in trainset.Vehicles)
+ {
+ var bmp = GetMiniBitmap(v.MiniName) ?? GetMiniBitmap(v.SkinFile);
+ if (bmp != null)
+ minis.Children.Add(new Image { Source = bmp, Height = 26, Stretch = Stretch.Uniform });
+ else
+ minis.Children.Add(new Border
+ {
+ Height = 26, Width = 44,
+ Background = new SolidColorBrush(Color.Parse("#22808080")),
+ CornerRadius = new CornerRadius(3)
+ });
+
+ names.Children.Add(new TextBlock { Text = v.SkinFile, FontSize = 10, Opacity = 0.8 });
+ }
+
+ var panel = new StackPanel { Spacing = 3 };
+ panel.Children.Add(minis);
+ panel.Children.Add(names);
+ return panel;
+ }
+
+ private void SceneryCombo_OnSelectionChanged(object? sender, SelectionChangedEventArgs e)
+ {
+ if (_suppress) return;
+ var scenery = (sceneryCombo.SelectedItem as ComboBoxItem)?.Tag as Scenery;
+ PopulateConsistCombo(scenery);
+ }
+
+ private void SceneryConsistCombo_OnSelectionChanged(object? sender, SelectionChangedEventArgs e)
+ {
+ if (_suppress) return;
+ if (sceneryConsistCombo.SelectedItem is not ComboBoxItem { Tag: Trainset trainset })
+ return;
+
+ _consist.Clear();
+ foreach (var vehicle in trainset.Vehicles)
+ _consist.Add(vehicle.Clone());
+ _selected = _consist.FirstOrDefault();
+ RebuildConsist();
+ }
+
+ // ------------------------------------------------------------ consist edits
+
+ private void AddFromTexture(VehicleTexture texture)
+ {
+ string skin = Path.GetFileNameWithoutExtension(texture.Skinfile);
+ var dyn = new Dynamic
+ {
+ RangeMax = -1,
+ RangeMin = -1,
+ Name = MakeUniqueName(skin),
+ DataFolder = StripDynamicPrefix(texture.Directory),
+ SkinFile = skin,
+ MmdFile = string.IsNullOrEmpty(texture.Model) ? skin : texture.Model!,
+ Offset = 0f,
+ DriverType = eDriverType.Nobody,
+ couplingData = 3,
+ Tail = "",
+ MiniName = _db.ResolveMiniName(texture)
+ };
+ _consist.Add(dyn);
+ _selected = dyn;
+ RebuildConsist();
+ }
+
+ private string MakeUniqueName(string baseName)
+ {
+ if (string.IsNullOrWhiteSpace(baseName)) baseName = "vehicle";
+ baseName = baseName.Replace(' ', '_');
+ return $"{baseName}_{++_nameCounter}";
+ }
+
+ private static string StripDynamicPrefix(string directory)
+ {
+ string d = directory.Replace('\\', '/').TrimEnd('/');
+ const string prefix = "dynamic/";
+ if (d.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
+ d = d[prefix.Length..];
+ return d;
+ }
+
+ private void MoveLeft(Dynamic d)
+ {
+ int i = _consist.IndexOf(d);
+ if (i > 0)
+ {
+ (_consist[i - 1], _consist[i]) = (_consist[i], _consist[i - 1]);
+ RebuildConsist();
+ }
+ }
+
+ private void MoveRight(Dynamic d)
+ {
+ int i = _consist.IndexOf(d);
+ if (i >= 0 && i < _consist.Count - 1)
+ {
+ (_consist[i + 1], _consist[i]) = (_consist[i], _consist[i + 1]);
+ RebuildConsist();
+ }
+ }
+
+ private void RemoveVehicle(Dynamic d)
+ {
+ _consist.Remove(d);
+ if (ReferenceEquals(_selected, d)) _selected = null;
+ RebuildConsist();
+ }
+
+ private void FlipVehicle(Dynamic d)
+ {
+ d.Flipped = !d.Flipped;
+ RebuildConsist();
+ }
+
+ private void CycleDriver(Dynamic d)
+ {
+ d.DriverType = d.DriverType switch
+ {
+ eDriverType.Nobody => eDriverType.Headdriver,
+ eDriverType.Headdriver => eDriverType.Reardriver,
+ eDriverType.Reardriver => eDriverType.Passenger,
+ _ => eDriverType.Nobody
+ };
+ RebuildConsist();
+ }
+
+ // ----------------------------------------------------------- consist render
+
+ private void RebuildConsist()
+ {
+ consistStack.Children.Clear();
+ for (int i = 0; i < _consist.Count; i++)
+ {
+ var d = _consist[i];
+ consistStack.Children.Add(BuildCard(d));
+ if (i < _consist.Count - 1)
+ consistStack.Children.Add(BuildCoupler(d));
+ }
+
+ emptyHint.IsVisible = _consist.Count == 0;
+ UpdateSummary();
+ UpdateDetails();
+ }
+
+ private Control BuildCard(Dynamic d)
+ {
+ bool selected = ReferenceEquals(_selected, d);
+
+ Control visual;
+ var bmp = GetMiniBitmap(d.MiniName) ?? GetMiniBitmap(d.SkinFile);
+ if (bmp != null)
+ {
+ var img = new Image { Height = 48, Stretch = Stretch.Uniform };
+ img.Source = bmp;
+ if (d.Flipped)
+ {
+ img.RenderTransform = new ScaleTransform(-1, 1);
+ img.RenderTransformOrigin = RelativePoint.Center;
+ }
+ visual = img;
+ }
+ else
+ {
+ visual = new Border
+ {
+ Height = 48,
+ Width = 96,
+ Background = new SolidColorBrush(Color.Parse("#22808080")),
+ CornerRadius = new CornerRadius(4),
+ Child = new TextBlock
+ {
+ Text = d.SkinFile,
+ FontSize = 10,
+ TextWrapping = TextWrapping.Wrap,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Center,
+ TextAlignment = TextAlignment.Center
+ }
+ };
+ }
+
+ var nameBlock = new TextBlock
+ {
+ Text = d.Name,
+ FontSize = 11,
+ MaxWidth = 110,
+ TextWrapping = TextWrapping.Wrap,
+ TextAlignment = TextAlignment.Center,
+ HorizontalAlignment = HorizontalAlignment.Center
+ };
+
+ var controls = new StackPanel
+ {
+ Orientation = Orientation.Horizontal,
+ Spacing = 2,
+ HorizontalAlignment = HorizontalAlignment.Center
+ };
+ controls.Children.Add(SmallButton("◀", App.Loc["TipMoveLeft"], () => MoveLeft(d)));
+ controls.Children.Add(DriverButton(d));
+ controls.Children.Add(SmallButton("⇄", App.Loc["TipFlip"], () => FlipVehicle(d)));
+ controls.Children.Add(SmallButton("✕", App.Loc["TipRemove"], () => RemoveVehicle(d)));
+ controls.Children.Add(SmallButton("▶", App.Loc["TipMoveRight"], () => MoveRight(d)));
+
+ var inner = new StackPanel { Spacing = 4, Width = 122 };
+ inner.Children.Add(visual);
+ inner.Children.Add(nameBlock);
+ inner.Children.Add(controls);
+
+ var card = new Border
+ {
+ Margin = new Thickness(2, 0),
+ Padding = new Thickness(6),
+ CornerRadius = new CornerRadius(6),
+ BorderThickness = new Thickness(selected ? 2 : 1),
+ BorderBrush = selected ? CardBorderSel : CardBorder,
+ Background = selected ? CardBgSel : Brushes.Transparent,
+ Cursor = _hand,
+ Child = inner
+ };
+ card.PointerPressed += (_, _) =>
+ {
+ _selected = d;
+ RebuildConsist();
+ };
+ return card;
+ }
+
+ // Coupling indicator between two vehicles. Hovering shows the 8-bit coupling
+ // editor (placeholder for now; full editing comes later).
+ private Control BuildCoupler(Dynamic d)
+ {
+ var glyph = new TextBlock
+ {
+ Text = "≣",
+ FontSize = 18,
+ Opacity = 0.7,
+ VerticalAlignment = VerticalAlignment.Center,
+ HorizontalAlignment = HorizontalAlignment.Center
+ };
+ var coupler = new Border
+ {
+ Width = 18,
+ Padding = new Thickness(2),
+ Cursor = _hand,
+ Background = Brushes.Transparent,
+ VerticalAlignment = VerticalAlignment.Center,
+ Child = glyph
+ };
+ ToolTip.SetTip(coupler, BuildCouplingBox(d));
+ ToolTip.SetShowDelay(coupler, 150);
+ return coupler;
+ }
+
+ private Control BuildCouplingBox(Dynamic d)
+ {
+ var panel = new StackPanel { Spacing = 2 };
+ panel.Children.Add(new TextBlock
+ {
+ Text = App.Loc["Coupling"],
+ FontWeight = FontWeight.Bold,
+ FontSize = 11,
+ Margin = new Thickness(0, 0, 0, 2)
+ });
+
+ for (int i = 0; i < CouplingBits.Length; i++)
+ {
+ panel.Children.Add(new CheckBox
+ {
+ Content = CouplingBits[i],
+ IsChecked = (d.couplingData & (1 << i)) != 0,
+ FontSize = 11
+ });
+ }
+
+ return new Border { Padding = new Thickness(8), Child = panel };
+ }
+
+ private Button SmallButton(string glyph, string tip, Action onClick)
+ {
+ var btn = new Button
+ {
+ Content = glyph,
+ FontSize = 12,
+ Padding = new Thickness(5, 1),
+ MinWidth = 0,
+ Cursor = _hand
+ };
+ btn.Classes.Add("Basic");
+ ToolTip.SetTip(btn, tip);
+ btn.Click += (_, _) => onClick();
+ return btn;
+ }
+
+ private Button DriverButton(Dynamic d)
+ {
+ (string glyph, string color) = d.DriverType switch
+ {
+ eDriverType.Headdriver => ("1", "#4CAF50"),
+ eDriverType.Reardriver => ("2", "#FF9800"),
+ eDriverType.Passenger => ("P", "#2196F3"),
+ _ => ("–", "#888888")
+ };
+
+ var btn = SmallButton(glyph, App.Loc["TipDriver"], () => CycleDriver(d));
+ btn.Foreground = new SolidColorBrush(Color.Parse(color));
+ btn.FontWeight = FontWeight.Bold;
+ return btn;
+ }
+
+ private void UpdateSummary()
+ {
+ int crewed = _consist.Count(v => v.DriverType is eDriverType.Headdriver or eDriverType.Reardriver);
+ summaryLabel.Text = $"{_consist.Count} {App.Loc["Vehicles"]} · {crewed} {App.Loc["Crew"]}";
+ }
+
+ // Brakes / Loads tabs are placeholders bound to the selected vehicle.
+ private void UpdateDetails()
+ {
+ if (_selected is null)
+ {
+ brakesContent.Text = App.Loc["SelectVehicleHint"];
+ loadsContent.Text = App.Loc["SelectVehicleHint"];
+ return;
+ }
+
+ var d = _selected;
+ brakesContent.Text =
+ $"{App.Loc["Vehicle"]}: {d.Name}\n" +
+ $"coupling: {d.couplingData}\n\n" +
+ $"{App.Loc["ComingSoon"]}";
+ loadsContent.Text =
+ $"{App.Loc["Vehicle"]}: {d.Name}\n\n" +
+ $"{App.Loc["ComingSoon"]}";
+ }
+
+ // ------------------------------------------------------------------- minis
+
+ private Bitmap? GetMiniBitmap(string? name)
+ {
+ if (string.IsNullOrEmpty(name))
+ return null;
+ if (_miniCache.TryGetValue(name, out var cached))
+ return cached;
+
+ string? path = VehicleDatabase.MiniPath(name);
+ Bitmap? bmp = null;
+ if (path != null)
+ {
+ try { bmp = new Bitmap(path); }
+ catch { bmp = null; }
+ }
+ _miniCache[name] = bmp;
+ return bmp;
+ }
+
+ // ------------------------------------------------------------------ events
+
+ private void SearchBox_OnTextChanged(object? sender, TextChangedEventArgs e)
+ {
+ if (_suppress) return;
+ BuildBrowser();
+ }
+
+ private void NewConsist_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
+ {
+ _consist.Clear();
+ _selected = null;
+ sceneryConsistCombo.SelectedItem = null;
+ RebuildConsist();
+ }
+}