From 0b0d93f701f581666b83ed0a9ad0471f207a1437 Mon Sep 17 00:00:00 2001
From: AsY!um- <377468+AsYlum-@users.noreply.github.com>
Date: Sat, 14 Nov 2026 16:19:08 +0100
Subject: [PATCH 1/5] Rework blueprints: own folder, names, footprints, bulk
save, groups
Prefabs become blueprints in MultiEditorBlueprints; open, import and Designs lists save them in bulk.
The Blueprints page gets a group drop-down, favorite groups and thumbnails in the canvas's order.
---
.../Classes/Blueprints.cs | 398 +++++++++++
.../Classes/MultiTile.cs | 35 +-
.../Classes/Prefabs.cs | 123 ----
.../Editing/EditorSession.Commands.cs | 9 +-
.../UI/BlueprintSaveDialog.Designer.cs | 122 ++++
.../UI/BlueprintSaveDialog.cs | 148 ++++
.../UI/BlueprintSaveDialog.resx | 120 ++++
.../UI/DesignLibraryPanel.Designer.cs | 16 +
.../UI/DesignLibraryPanel.cs | 47 +-
UoFiddler.Plugin.MultiEditor/UI/DocumentIO.cs | 96 ++-
.../UI/DocumentIOPickDialog.Designer.cs | 15 +
.../UI/DocumentIOPickDialog.cs | 44 +-
.../UI/EditorSettings.cs | 6 +
.../UI/FolderImportDialog.Designer.cs | 18 +-
.../UI/FolderImportDialog.cs | 39 +-
.../UI/MultiEditorControl.cs | 46 +-
.../UI/TileBrowserPanel.Designer.cs | 111 +--
.../UI/TileBrowserPanel.cs | 653 +++++++++++++-----
.../UoFiddler.Plugin.MultiEditor.csproj | 9 +
19 files changed, 1628 insertions(+), 427 deletions(-)
create mode 100644 UoFiddler.Plugin.MultiEditor/Classes/Blueprints.cs
delete mode 100644 UoFiddler.Plugin.MultiEditor/Classes/Prefabs.cs
create mode 100644 UoFiddler.Plugin.MultiEditor/UI/BlueprintSaveDialog.Designer.cs
create mode 100644 UoFiddler.Plugin.MultiEditor/UI/BlueprintSaveDialog.cs
create mode 100644 UoFiddler.Plugin.MultiEditor/UI/BlueprintSaveDialog.resx
diff --git a/UoFiddler.Plugin.MultiEditor/Classes/Blueprints.cs b/UoFiddler.Plugin.MultiEditor/Classes/Blueprints.cs
new file mode 100644
index 00000000..87318e03
--- /dev/null
+++ b/UoFiddler.Plugin.MultiEditor/Classes/Blueprints.cs
@@ -0,0 +1,398 @@
+/***************************************************************************
+ *
+ * $Author: Turley
+ *
+ * "THE BEER-WARE LICENSE"
+ * As long as you retain this notice you can do whatever you want with
+ * this stuff. If we meet some day, and you think this stuff is worth it,
+ * you can buy me a beer in return.
+ *
+ ***************************************************************************/
+
+using System;
+using System.Collections.Generic;
+using System.Drawing;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Text;
+using Ultima;
+using UoFiddler.Controls.Classes;
+using UoFiddler.Plugin.MultiEditor.Tools;
+
+namespace UoFiddler.Plugin.MultiEditor.Classes
+{
+ ///
+ /// A blueprint to write: its name, how many tiles it has (as far as known before reading it,
+ /// for the size warning) and its tiles, read only when written.
+ ///
+ internal sealed record BlueprintSource(string Name, int TileCount, Func> Tiles);
+
+ /// What a call wrote and what it could not.
+ internal sealed record BlueprintSaveResult(int Saved, List Failures);
+
+ ///
+ /// Saved groups of tiles to stamp again ("blueprints"), one multi text file each in the profile's
+ /// MultiEditorBlueprints folder (the same lines the clipboard uses). A subfolder is a group. The
+ /// first line starting with # holds the blueprint's name; files without one go by their file name.
+ ///
+ internal static class Blueprints
+ {
+ /// Blueprints above this many tiles are confirmed before they are saved.
+ public const int LargeBlueprintTiles = 2000;
+
+ private const string _folderName = "MultiEditorBlueprints";
+
+ /// The folder blueprints were kept in when they were called prefabs.
+ private const string _oldFolderName = "MultiEditorPrefabs";
+
+ private static string _migratedFrom;
+
+ /// The blueprint folder. The old prefab folder is moved into it the first time it is asked for.
+ public static string Folder
+ {
+ get
+ {
+ string folder = Path.Combine(Options.AppDataPath, _folderName);
+ string old = Path.Combine(Options.AppDataPath, _oldFolderName);
+ if (_migratedFrom != old)
+ {
+ _migratedFrom = old;
+ MoveOldFolder(old, folder);
+ }
+
+ return folder;
+ }
+ }
+
+ ///
+ /// Moves the old prefab folder to the blueprint folder: renamed when there is no blueprint
+ /// folder yet, else merged file by file (groups too), leaving anything whose name is taken.
+ /// The old folder is removed once it is empty. Failures leave the files where they are.
+ ///
+ private static void MoveOldFolder(string old, string folder)
+ {
+ try
+ {
+ if (!Directory.Exists(old))
+ {
+ return;
+ }
+
+ if (!Directory.Exists(folder))
+ {
+ Directory.Move(old, folder);
+ return;
+ }
+
+ MergeFolder(old, folder);
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ // The old files stay; the next start tries again.
+ }
+ }
+
+ private static void MergeFolder(string from, string to)
+ {
+ Directory.CreateDirectory(to);
+ foreach (string file in Directory.EnumerateFiles(from).ToList())
+ {
+ string target = Path.Combine(to, Path.GetFileName(file));
+ if (!File.Exists(target))
+ {
+ File.Move(file, target);
+ }
+ }
+
+ foreach (string sub in Directory.EnumerateDirectories(from).ToList())
+ {
+ MergeFolder(sub, Path.Combine(to, Path.GetFileName(sub)));
+ }
+
+ if (!Directory.EnumerateFileSystemEntries(from).Any())
+ {
+ Directory.Delete(from);
+ }
+ }
+
+ /// The folder of a group; the blueprint folder itself for no group.
+ public static string GroupFolder(string group)
+ {
+ return string.IsNullOrEmpty(group) ? Folder : Path.Combine(Folder, group);
+ }
+
+ /// The blueprint groups (subfolders), read from disk.
+ public static List Groups()
+ {
+ try
+ {
+ string folder = Folder;
+ return Directory.Exists(folder)
+ ? Directory.EnumerateDirectories(folder).Select(Path.GetFileName).OrderBy(f => f, StringComparer.OrdinalIgnoreCase).ToList()
+ : new List();
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ return new List();
+ }
+ }
+
+ /// The group the last bulk save went to, offered first next time; empty for no group.
+ public static string LastGroup { get; set; } = string.Empty;
+
+ /// A file name made safe from what the user typed.
+ public static string SafeName(string name)
+ {
+ foreach (char c in Path.GetInvalidFileNameChars())
+ {
+ name = name.Replace(c, '_');
+ }
+
+ return name.Trim();
+ }
+
+ ///
+ /// Reads a blueprint file: its tiles (null when the file is missing or holds a line that is
+ /// neither a tile nor a # line) and its name: the first # line, else the file name.
+ ///
+ public static (List Tiles, string Name) Read(string path)
+ {
+ string fileName = Path.GetFileNameWithoutExtension(path);
+ try
+ {
+ if (!File.Exists(path))
+ {
+ return (null, fileName);
+ }
+
+ (List tiles, string name) = Parse(File.ReadAllText(path));
+ return (tiles, name.Length > 0 ? name : fileName);
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ return (null, fileName);
+ }
+ }
+
+ /// Blueprint file text: its tiles and the name from its first # line (empty without one).
+ public static (List Tiles, string Name) Parse(string text)
+ {
+ string name = null;
+ var lines = new StringBuilder();
+ foreach (string raw in text.Split('\n'))
+ {
+ string line = raw.Trim();
+ if (line.StartsWith('#'))
+ {
+ name ??= line[1..].Trim();
+ }
+ else if (line.Length > 0)
+ {
+ lines.Append(line).Append('\n');
+ }
+ }
+
+ return (ClipboardTile.Parse(lines.ToString()), name ?? string.Empty);
+ }
+
+ /// The file text: the name (on one line) when there is one, then the tile lines.
+ public static string Format(IEnumerable tiles, string name)
+ {
+ string text = ClipboardTile.Format(tiles);
+ string line = OneLine(name);
+ return line.Length == 0 ? text : $"# {line}\r\n{text}";
+ }
+
+ public static void Write(string path, IEnumerable tiles, string name)
+ {
+ Directory.CreateDirectory(Path.GetDirectoryName(path));
+ File.WriteAllText(path, Format(tiles, name));
+ }
+
+ ///
+ /// A free name for a blueprint in a folder and the file for it: the name as given, or with
+ /// " (2)", " (3)"... added when its file is taken. is a file that
+ /// does not count as taken (the blueprint being renamed).
+ ///
+ public static (string Path, string Name) UniqueName(string folder, string name, string own = null)
+ {
+ string baseName = OneLine(name);
+ if (SafeName(baseName).Length == 0)
+ {
+ baseName = "Blueprint";
+ }
+
+ string candidate = baseName;
+ string path = Path.Combine(folder, SafeName(candidate) + ".txt");
+ for (int n = 2; File.Exists(path) && !string.Equals(Path.GetFullPath(path), own == null ? null : Path.GetFullPath(own), StringComparison.OrdinalIgnoreCase); ++n)
+ {
+ candidate = string.Create(CultureInfo.InvariantCulture, $"{baseName} ({n})");
+ path = Path.Combine(folder, SafeName(candidate) + ".txt");
+ }
+
+ return (path, candidate);
+ }
+
+ ///
+ /// How much room the tiles take: cells along X and Y, and Z from the lowest tile to the top
+ /// of the highest (its tiledata height included).
+ ///
+ public static (int Width, int Depth, int Height) Footprint(IReadOnlyCollection tiles)
+ {
+ if (tiles == null || tiles.Count == 0)
+ {
+ return (0, 0, 0);
+ }
+
+ int minX = int.MaxValue, minY = int.MaxValue, minZ = int.MaxValue;
+ int maxX = int.MinValue, maxY = int.MinValue, top = int.MinValue;
+ foreach (ClipboardTile tile in tiles)
+ {
+ minX = Math.Min(minX, tile.Dx);
+ minY = Math.Min(minY, tile.Dy);
+ minZ = Math.Min(minZ, tile.Dz);
+ maxX = Math.Max(maxX, tile.Dx);
+ maxY = Math.Max(maxY, tile.Dy);
+ int height = TileData.ItemTable != null && tile.Id < TileData.ItemTable.Length ? TileData.ItemTable[tile.Id].Height : 0;
+ top = Math.Max(top, tile.Dz + height);
+ }
+
+ return (maxX - minX + 1, maxY - minY + 1, top - minZ);
+ }
+
+ /// A footprint as text: "12×9", with the height when .
+ public static string FootprintText((int Width, int Depth, int Height) size, bool withHeight)
+ {
+ string cells = string.Create(CultureInfo.InvariantCulture, $"{size.Width}×{size.Depth}");
+ return withHeight ? string.Create(CultureInfo.InvariantCulture, $"{cells} cells, {size.Height} Z high") : cells;
+ }
+
+ ///
+ /// A multi's tiles as blueprint tiles from its lowest X, Y and Z, keeping each tile's
+ /// visibility flag and extra field the way the editor reads them.
+ ///
+ public static List FromComponents(MultiComponentList list)
+ {
+ var tiles = new List();
+ if (list == null || list == MultiComponentList.Empty)
+ {
+ return tiles;
+ }
+
+ for (int x = 0; x < list.Width; ++x)
+ {
+ for (int y = 0; y < list.Height; ++y)
+ {
+ foreach (MTile tile in list.Tiles[x][y])
+ {
+ tiles.Add(new ClipboardTile(tile.Id, x, y, tile.Z, tile.Flag == 0, tile.Unk1));
+ }
+ }
+ }
+
+ if (tiles.Count == 0)
+ {
+ return tiles;
+ }
+
+ int minX = tiles.Min(t => t.Dx), minY = tiles.Min(t => t.Dy), minZ = tiles.Min(t => t.Dz);
+ return tiles.Select(t => t with { Dx = t.Dx - minX, Dy = t.Dy - minY, Dz = t.Dz - minZ }).ToList();
+ }
+
+ ///
+ /// Writes each source into a group's folder under a free name; a source that fails or has
+ /// no tiles is listed with the reason and the rest are still written.
+ ///
+ /// The group for each source; empty for no group.
+ public static BlueprintSaveResult SaveMany(IEnumerable sources, Func groupOf)
+ {
+ int saved = 0;
+ var failures = new List();
+ foreach (BlueprintSource source in sources)
+ {
+ try
+ {
+ List tiles = source.Tiles();
+ if (tiles == null || tiles.Count == 0)
+ {
+ failures.Add($"{source.Name}: no tiles");
+ continue;
+ }
+
+ string folder = GroupFolder(SafeName(groupOf(source) ?? string.Empty));
+ Directory.CreateDirectory(folder);
+ (string path, string name) = UniqueName(folder, source.Name);
+ Write(path, tiles, name);
+ ++saved;
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or EndOfStreamException or ArgumentException)
+ {
+ failures.Add($"{source.Name}: {ex.Message}");
+ }
+ }
+
+ return new BlueprintSaveResult(saved, failures);
+ }
+
+ ///
+ /// A small picture of the blueprint: its visible tiles drawn the way the editor draws them, scaled to fit.
+ ///
+ public static Bitmap Thumbnail(IReadOnlyList tiles, int size)
+ {
+ var placed = new List<(ClipboardTile Tile, Bitmap Art, int X, int Y)>();
+ int minX = int.MaxValue, minY = int.MaxValue, maxX = int.MinValue, maxY = int.MinValue;
+ foreach (ClipboardTile tile in tiles)
+ {
+ Bitmap art = tile.Invisible ? null : Art.GetStatic(tile.Id);
+ if (art == null)
+ {
+ continue;
+ }
+
+ int x = ((tile.Dx - tile.Dy) * 22) - (art.Width / 2);
+ int y = ((tile.Dx + tile.Dy) * 22) - (tile.Dz * 4) - art.Height;
+ placed.Add((tile, art, x, y));
+ minX = Math.Min(minX, x);
+ minY = Math.Min(minY, y);
+ maxX = Math.Max(maxX, x + art.Width);
+ maxY = Math.Max(maxY, y + art.Height);
+ }
+
+ var thumb = new Bitmap(size, size);
+ if (placed.Count == 0)
+ {
+ return thumb;
+ }
+
+ using var full = new Bitmap(Math.Max(1, maxX - minX), Math.Max(1, maxY - minY));
+ using (Graphics g = Graphics.FromImage(full))
+ {
+ // The editor's stacking order (MultiTile.CompareTo): back to front by cell diagonal, then
+ // Z raised by the sort threshold, then the threshold, then file order (OrderBy is stable).
+ foreach (var p in placed.OrderBy(p => p.Tile.Dx + p.Tile.Dy)
+ .ThenBy(p => p.Tile.Dz + MultiTile.SortThresholdOf(p.Tile.Id))
+ .ThenBy(p => MultiTile.SortThresholdOf(p.Tile.Id)))
+ {
+ g.DrawImageUnscaled(p.Art, p.X - minX, p.Y - minY);
+ }
+ }
+
+ using (Graphics g = Graphics.FromImage(thumb))
+ {
+ g.InterpolationMode = System.Drawing.Drawing2D.InterpolationMode.HighQualityBicubic;
+ float scale = Math.Min(1f, Math.Min((float)size / full.Width, (float)size / full.Height));
+ int w = Math.Max(1, (int)(full.Width * scale));
+ int h = Math.Max(1, (int)(full.Height * scale));
+ g.DrawImage(full, (size - w) / 2, (size - h) / 2, w, h);
+ }
+
+ return thumb;
+ }
+
+ private static string OneLine(string text)
+ {
+ return string.IsNullOrWhiteSpace(text) ? string.Empty : string.Join(' ', text.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)).Trim();
+ }
+ }
+}
diff --git a/UoFiddler.Plugin.MultiEditor/Classes/MultiTile.cs b/UoFiddler.Plugin.MultiEditor/Classes/MultiTile.cs
index bdf71aec..bdacb6ab 100644
--- a/UoFiddler.Plugin.MultiEditor/Classes/MultiTile.cs
+++ b/UoFiddler.Plugin.MultiEditor/Classes/MultiTile.cs
@@ -168,24 +168,33 @@ public int CompareTo(object obj)
/// The height a tile sorts at in its cell's stack: its Z, raised for tall and non-background tiles.
public int SortKey => Z + SortThreshold;
- private int SortThreshold
+ private int SortThreshold => SortThresholdOf(Id);
+
+ ///
+ /// What a tile adds to its Z when its cell is stacked: one for a tile with height, one for a
+ /// tile that is not background. So foliage (low, not background) goes over its trunk (tall,
+ /// background) at the same Z. Shared with the blueprint thumbnails so they stack the same way.
+ ///
+ public static int SortThresholdOf(ushort id)
{
- get
+ if (TileData.ItemTable == null || id >= TileData.ItemTable.Length)
{
- ItemData data = TileData.ItemTable[Id];
- int threshold = 0;
- if (data.Height > 0)
- {
- ++threshold;
- }
+ return 0;
+ }
- if (!data.Background)
- {
- ++threshold;
- }
+ ItemData data = TileData.ItemTable[id];
+ int threshold = 0;
+ if (data.Height > 0)
+ {
+ ++threshold;
+ }
- return threshold;
+ if (!data.Background)
+ {
+ ++threshold;
}
+
+ return threshold;
}
public void Set(ushort id, int z)
diff --git a/UoFiddler.Plugin.MultiEditor/Classes/Prefabs.cs b/UoFiddler.Plugin.MultiEditor/Classes/Prefabs.cs
deleted file mode 100644
index ad9a9449..00000000
--- a/UoFiddler.Plugin.MultiEditor/Classes/Prefabs.cs
+++ /dev/null
@@ -1,123 +0,0 @@
-/***************************************************************************
- *
- * $Author: Turley
- *
- * "THE BEER-WARE LICENSE"
- * As long as you retain this notice you can do whatever you want with
- * this stuff. If we meet some day, and you think this stuff is worth it,
- * you can buy me a beer in return.
- *
- ***************************************************************************/
-
-using System;
-using System.Collections.Generic;
-using System.Drawing;
-using System.IO;
-using System.Linq;
-using Ultima;
-using UoFiddler.Controls.Classes;
-using UoFiddler.Plugin.MultiEditor.Tools;
-
-namespace UoFiddler.Plugin.MultiEditor.Classes
-{
- ///
- /// Saved groups of tiles to stamp again, one multi text file per prefab in the profile's
- /// MultiEditorPrefabs folder (the same lines the clipboard uses).
- ///
- internal static class Prefabs
- {
- public static string Folder => Path.Combine(Options.AppDataPath, "MultiEditorPrefabs");
-
- public static IEnumerable Names()
- {
- return Directory.Exists(Folder)
- ? Directory.EnumerateFiles(Folder, "*.txt").Select(Path.GetFileNameWithoutExtension).OrderBy(n => n, StringComparer.OrdinalIgnoreCase)
- : Enumerable.Empty();
- }
-
- public static List Load(string name)
- {
- string path = PathOf(name);
- return File.Exists(path) ? ClipboardTile.Parse(File.ReadAllText(path)) : null;
- }
-
- public static void Save(string name, IEnumerable tiles)
- {
- Directory.CreateDirectory(Folder);
- File.WriteAllText(PathOf(name), ClipboardTile.Format(tiles));
- }
-
- public static void Delete(string name)
- {
- string path = PathOf(name);
- if (File.Exists(path))
- {
- File.Delete(path);
- }
- }
-
- /// A file name made safe from what the user typed.
- public static string SafeName(string name)
- {
- foreach (char c in Path.GetInvalidFileNameChars())
- {
- name = name.Replace(c, '_');
- }
-
- return name.Trim();
- }
-
- ///
- /// A small picture of the prefab: its tiles drawn the way the editor draws them, scaled to fit.
- ///
- public static Bitmap Thumbnail(IReadOnlyList tiles, int size)
- {
- var placed = new List<(ClipboardTile Tile, Bitmap Art, int X, int Y)>();
- int minX = int.MaxValue, minY = int.MaxValue, maxX = int.MinValue, maxY = int.MinValue;
- foreach (ClipboardTile tile in tiles)
- {
- Bitmap art = Art.GetStatic(tile.Id);
- if (art == null)
- {
- continue;
- }
-
- int x = ((tile.Dx - tile.Dy) * 22) - (art.Width / 2);
- int y = ((tile.Dx + tile.Dy) * 22) - (tile.Dz * 4) - art.Height;
- placed.Add((tile, art, x, y));
- minX = Math.Min(minX, x);
- minY = Math.Min(minY, y);
- maxX = Math.Max(maxX, x + art.Width);
- maxY = Math.Max(maxY, y + art.Height);
- }
-
- var thumb = new Bitmap(size, size);
- if (placed.Count == 0)
- {
- return thumb;
- }
-
- using var full = new Bitmap(Math.Max(1, maxX - minX), Math.Max(1, maxY - minY));
- using (Graphics g = Graphics.FromImage(full))
- {
- foreach (var p in placed.OrderBy(p => p.Tile.Dx + p.Tile.Dy).ThenBy(p => p.Tile.Dz).ThenBy(p => TileData.ItemTable[p.Tile.Id].Height))
- {
- g.DrawImageUnscaled(p.Art, p.X - minX, p.Y - minY);
- }
- }
-
- using (Graphics g = Graphics.FromImage(thumb))
- {
- g.InterpolationMode = System.Drawing.Drawing2D.InterpolationMode.HighQualityBicubic;
- float scale = Math.Min(1f, Math.Min((float)size / full.Width, (float)size / full.Height));
- int w = Math.Max(1, (int)(full.Width * scale));
- int h = Math.Max(1, (int)(full.Height * scale));
- g.DrawImage(full, (size - w) / 2, (size - h) / 2, w, h);
- }
-
- return thumb;
- }
-
- private static string PathOf(string name) => Path.Combine(Folder, SafeName(name) + ".txt");
- }
-}
diff --git a/UoFiddler.Plugin.MultiEditor/Editing/EditorSession.Commands.cs b/UoFiddler.Plugin.MultiEditor/Editing/EditorSession.Commands.cs
index dad30bb4..89a19463 100644
--- a/UoFiddler.Plugin.MultiEditor/Editing/EditorSession.Commands.cs
+++ b/UoFiddler.Plugin.MultiEditor/Editing/EditorSession.Commands.cs
@@ -104,12 +104,19 @@ public void Paste()
public bool CanPaste => _clipboard != null || ClipboardHasTiles();
- /// The selection as tiles relative to its lowest X, Y and Z (also used to save a prefab).
+ /// The selection as tiles relative to its lowest X, Y and Z (also used to save a blueprint).
public List SelectionAsClipboard()
{
return _selection.Count == 0 ? null : ToClipboard(_selection);
}
+ /// Every tile of the design, relative to its lowest X, Y and Z; null when it is empty.
+ public List DesignAsClipboard()
+ {
+ List tiles = CompList.RealTiles.ToList();
+ return tiles.Count == 0 ? null : ToClipboard(tiles);
+ }
+
private static List ToClipboard(IReadOnlyCollection tiles)
{
int ox = tiles.Min(t => t.X);
diff --git a/UoFiddler.Plugin.MultiEditor/UI/BlueprintSaveDialog.Designer.cs b/UoFiddler.Plugin.MultiEditor/UI/BlueprintSaveDialog.Designer.cs
new file mode 100644
index 00000000..0ef2955b
--- /dev/null
+++ b/UoFiddler.Plugin.MultiEditor/UI/BlueprintSaveDialog.Designer.cs
@@ -0,0 +1,122 @@
+namespace UoFiddler.Plugin.MultiEditor.UI
+{
+ partial class BlueprintSaveDialog
+ {
+ ///
+ /// Required designer variable.
+ ///
+ private System.ComponentModel.IContainer components = null;
+
+ ///
+ /// Clean up any resources being used.
+ ///
+ /// true if managed resources should be disposed; otherwise, false.
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing && (components != null))
+ {
+ components.Dispose();
+ }
+ base.Dispose(disposing);
+ }
+
+ #region Windows Form Designer generated code
+
+ ///
+ /// Required method for Designer support - do not modify
+ /// the contents of this method with the code editor.
+ ///
+ private void InitializeComponent()
+ {
+ _summary = new System.Windows.Forms.Label();
+ _groupLabel = new System.Windows.Forms.Label();
+ _group = new System.Windows.Forms.ComboBox();
+ _split = new System.Windows.Forms.CheckBox();
+ _okButton = new System.Windows.Forms.Button();
+ _cancelButton = new System.Windows.Forms.Button();
+ SuspendLayout();
+ //
+ // _summary
+ //
+ _summary.Location = new System.Drawing.Point(12, 12);
+ _summary.Name = "_summary";
+ _summary.Size = new System.Drawing.Size(356, 46);
+ _summary.TabIndex = 0;
+ //
+ // _groupLabel
+ //
+ _groupLabel.AutoSize = true;
+ _groupLabel.Location = new System.Drawing.Point(12, 68);
+ _groupLabel.Name = "_groupLabel";
+ _groupLabel.Size = new System.Drawing.Size(43, 15);
+ _groupLabel.TabIndex = 1;
+ _groupLabel.Text = "Group:";
+ //
+ // _group
+ //
+ _group.FormattingEnabled = true;
+ _group.Location = new System.Drawing.Point(70, 64);
+ _group.Name = "_group";
+ _group.Size = new System.Drawing.Size(298, 23);
+ _group.TabIndex = 2;
+ //
+ // _split
+ //
+ _split.Location = new System.Drawing.Point(12, 96);
+ _split.Name = "_split";
+ _split.Size = new System.Drawing.Size(356, 38);
+ _split.TabIndex = 3;
+ _split.UseVisualStyleBackColor = true;
+ _split.Visible = false;
+ //
+ // _okButton
+ //
+ _okButton.DialogResult = System.Windows.Forms.DialogResult.OK;
+ _okButton.Location = new System.Drawing.Point(208, 142);
+ _okButton.Name = "_okButton";
+ _okButton.Size = new System.Drawing.Size(76, 23);
+ _okButton.TabIndex = 4;
+ _okButton.Text = "Save";
+ //
+ // _cancelButton
+ //
+ _cancelButton.DialogResult = System.Windows.Forms.DialogResult.Cancel;
+ _cancelButton.Location = new System.Drawing.Point(292, 142);
+ _cancelButton.Name = "_cancelButton";
+ _cancelButton.Size = new System.Drawing.Size(76, 23);
+ _cancelButton.TabIndex = 5;
+ _cancelButton.Text = "Cancel";
+ //
+ // BlueprintSaveDialog
+ //
+ AcceptButton = _okButton;
+ AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
+ CancelButton = _cancelButton;
+ ClientSize = new System.Drawing.Size(380, 177);
+ Controls.Add(_summary);
+ Controls.Add(_groupLabel);
+ Controls.Add(_group);
+ Controls.Add(_split);
+ Controls.Add(_okButton);
+ Controls.Add(_cancelButton);
+ FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedDialog;
+ MaximizeBox = false;
+ MinimizeBox = false;
+ Name = "BlueprintSaveDialog";
+ ShowInTaskbar = false;
+ StartPosition = System.Windows.Forms.FormStartPosition.CenterParent;
+ Text = "Save as blueprints";
+ ResumeLayout(false);
+ PerformLayout();
+ }
+
+ #endregion
+
+ private System.Windows.Forms.Label _summary;
+ private System.Windows.Forms.Label _groupLabel;
+ private System.Windows.Forms.ComboBox _group;
+ private System.Windows.Forms.CheckBox _split;
+ private System.Windows.Forms.Button _okButton;
+ private System.Windows.Forms.Button _cancelButton;
+ }
+}
diff --git a/UoFiddler.Plugin.MultiEditor/UI/BlueprintSaveDialog.cs b/UoFiddler.Plugin.MultiEditor/UI/BlueprintSaveDialog.cs
new file mode 100644
index 00000000..2155905e
--- /dev/null
+++ b/UoFiddler.Plugin.MultiEditor/UI/BlueprintSaveDialog.cs
@@ -0,0 +1,148 @@
+/***************************************************************************
+ *
+ * "THE BEER-WARE LICENSE"
+ * As long as you retain this notice you can do whatever you want with
+ * this stuff. If we meet some day, and you think this stuff is worth it,
+ * you can buy me a beer in return.
+ *
+ ***************************************************************************/
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Windows.Forms;
+using UoFiddler.Plugin.MultiEditor.Classes;
+
+namespace UoFiddler.Plugin.MultiEditor.UI
+{
+ ///
+ /// Asks which blueprint group several multis are saved to, optionally one group each by
+ /// something the caller knows (a design's category, the file it came from). Shown through
+ /// .
+ ///
+ internal partial class BlueprintSaveDialog : Form
+ {
+ private const string _noGroup = "(no group)";
+ private const string _caption = "Save as blueprints";
+
+ public BlueprintSaveDialog()
+ {
+ InitializeComponent();
+ }
+
+ /// The text of the "one group each" check box; null hides it.
+ public BlueprintSaveDialog(string summary, IEnumerable groups, string group, string splitText)
+ : this()
+ {
+ _summary.Text = summary;
+ _group.Items.Add(_noGroup);
+ foreach (string name in groups)
+ {
+ _group.Items.Add(name);
+ }
+
+ _group.Text = string.IsNullOrEmpty(group) ? _noGroup : group;
+ if (splitText != null)
+ {
+ _split.Text = splitText;
+ _split.Visible = true;
+ }
+ }
+
+ /// The group typed or chosen, made safe for a folder name; empty for no group.
+ public string Group
+ {
+ get
+ {
+ string text = _group.Text.Trim();
+ return text.Length == 0 || text == _noGroup ? string.Empty : Blueprints.SafeName(text);
+ }
+ }
+
+ /// Whether each multi goes to its own group, as the caller decides.
+ public bool Split => _split.Visible && _split.Checked;
+
+ ///
+ /// Asks for the group and writes the blueprints, then lists any that could not be written.
+ /// Null when cancelled.
+ ///
+ /// The group to show afterwards: the one chosen, or null (all) when split.
+ /// Offers one group per source with this check box text, using .
+ /// The group of a source when split; empty puts it in the chosen group.
+ public static BlueprintSaveResult Run(IWin32Window owner, IReadOnlyList sources, out string showGroup,
+ string splitText = null, Func splitGroup = null)
+ {
+ showGroup = null;
+ if (sources.Count == 0)
+ {
+ return null;
+ }
+
+ int large = sources.Count(s => s.TileCount > Blueprints.LargeBlueprintTiles);
+ string summary = sources.Count == 1 ? $"Save \"{sources[0].Name}\" as a blueprint." : $"Save {sources.Count:N0} multis as blueprints.";
+ if (large > 0)
+ {
+ summary += large == sources.Count && large == 1
+ ? $" It has {sources[0].TileCount:N0} tiles, which is slow to place and preview."
+ : $" {large:N0} of them have more than {Blueprints.LargeBlueprintTiles:N0} tiles, which is slow to place and preview.";
+ }
+
+ string group;
+ bool split;
+ using (var dialog = new BlueprintSaveDialog(summary, Blueprints.Groups(), Blueprints.LastGroup, splitGroup != null ? splitText : null))
+ {
+ if (dialog.ShowDialog(owner) != DialogResult.OK)
+ {
+ return null;
+ }
+
+ group = dialog.Group;
+ split = dialog.Split;
+ }
+
+ Blueprints.LastGroup = group;
+ Func groupOf = split
+ ? s => splitGroup(s) is { Length: > 0 } own ? own : group
+ : _ => group;
+
+ BlueprintSaveResult result;
+ Cursor previous = Cursor.Current;
+ Cursor.Current = Cursors.WaitCursor;
+ try
+ {
+ result = Blueprints.SaveMany(sources, groupOf);
+ }
+ finally
+ {
+ Cursor.Current = previous;
+ }
+
+ if (result.Failures.Count > 0)
+ {
+ var text = new StringBuilder($"{result.Saved:N0} saved, {result.Failures.Count:N0} could not be saved:\r\n\r\n");
+ foreach (string failure in result.Failures.Take(20))
+ {
+ text.Append(failure).Append("\r\n");
+ }
+
+ if (result.Failures.Count > 20)
+ {
+ text.Append($"...and {result.Failures.Count - 20:N0} more");
+ }
+
+ MessageBox.Show(owner, text.ToString(), _caption, MessageBoxButtons.OK, MessageBoxIcon.Warning);
+ }
+
+ showGroup = split ? null : group;
+ return result;
+ }
+
+ /// The status line after a save, for the dialog the multis were picked in.
+ public static string Describe(BlueprintSaveResult result, string showGroup)
+ {
+ string where = showGroup == null ? "their groups" : showGroup.Length == 0 ? "no group" : $"group \"{showGroup}\"";
+ return $"Saved {result.Saved:N0} blueprint(s) to {where}.";
+ }
+ }
+}
diff --git a/UoFiddler.Plugin.MultiEditor/UI/BlueprintSaveDialog.resx b/UoFiddler.Plugin.MultiEditor/UI/BlueprintSaveDialog.resx
new file mode 100644
index 00000000..8b2ff64a
--- /dev/null
+++ b/UoFiddler.Plugin.MultiEditor/UI/BlueprintSaveDialog.resx
@@ -0,0 +1,120 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ text/microsoft-resx
+
+
+ 2.0
+
+
+ System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
\ No newline at end of file
diff --git a/UoFiddler.Plugin.MultiEditor/UI/DesignLibraryPanel.Designer.cs b/UoFiddler.Plugin.MultiEditor/UI/DesignLibraryPanel.Designer.cs
index c83b734c..30193b50 100644
--- a/UoFiddler.Plugin.MultiEditor/UI/DesignLibraryPanel.Designer.cs
+++ b/UoFiddler.Plugin.MultiEditor/UI/DesignLibraryPanel.Designer.cs
@@ -41,6 +41,7 @@ private void InitializeComponent()
buttons = new System.Windows.Forms.FlowLayoutPanel();
_open = new System.Windows.Forms.Button();
_place = new System.Windows.Forms.Button();
+ _saveBlueprints = new System.Windows.Forms.Button();
_filter = new System.Windows.Forms.TextBox();
bar = new System.Windows.Forms.ToolStrip();
chooseButton = new System.Windows.Forms.ToolStripButton();
@@ -142,6 +143,9 @@ private void InitializeComponent()
//
buttons.Controls.Add(_open);
buttons.Controls.Add(_place);
+ buttons.Controls.Add(_saveBlueprints);
+ buttons.AutoSize = true;
+ buttons.AutoSizeMode = System.Windows.Forms.AutoSizeMode.GrowAndShrink;
buttons.Dock = System.Windows.Forms.DockStyle.Bottom;
buttons.Location = new System.Drawing.Point(0, 118);
buttons.Name = "buttons";
@@ -170,6 +174,17 @@ private void InitializeComponent()
_place.UseVisualStyleBackColor = true;
_place.Click += Place_Click;
//
+ // _saveBlueprints
+ //
+ _saveBlueprints.Enabled = false;
+ _saveBlueprints.Location = new System.Drawing.Point(3, 67);
+ _saveBlueprints.Name = "_saveBlueprints";
+ _saveBlueprints.Size = new System.Drawing.Size(150, 26);
+ _saveBlueprints.TabIndex = 2;
+ _saveBlueprints.Text = "Save as blueprints...";
+ _saveBlueprints.UseVisualStyleBackColor = true;
+ _saveBlueprints.Click += SaveBlueprints_Click;
+ //
// _filter
//
_filter.Dock = System.Windows.Forms.DockStyle.Top;
@@ -252,6 +267,7 @@ private void InitializeComponent()
private System.Windows.Forms.FlowLayoutPanel buttons;
private System.Windows.Forms.Button _open;
private System.Windows.Forms.Button _place;
+ private System.Windows.Forms.Button _saveBlueprints;
private System.Windows.Forms.TextBox _filter;
private System.Windows.Forms.ToolStrip bar;
private System.Windows.Forms.ToolStripButton chooseButton;
diff --git a/UoFiddler.Plugin.MultiEditor/UI/DesignLibraryPanel.cs b/UoFiddler.Plugin.MultiEditor/UI/DesignLibraryPanel.cs
index 1859cbca..18499ded 100644
--- a/UoFiddler.Plugin.MultiEditor/UI/DesignLibraryPanel.cs
+++ b/UoFiddler.Plugin.MultiEditor/UI/DesignLibraryPanel.cs
@@ -120,6 +120,46 @@ private void Place_Click(object sender, EventArgs e)
}
}
+ /// Saves the selected designs as blueprints, optionally one group per design category.
+ private void SaveBlueprints_Click(object sender, EventArgs e)
+ {
+ List designs = SelectedDesigns;
+ if (designs.Count == 0)
+ {
+ return;
+ }
+
+ var designOf = new Dictionary();
+ var sources = new List();
+ foreach (UoaDesign design in designs)
+ {
+ var source = new BlueprintSource(DocumentIO.DesignTitle(design), design.ItemCount,
+ () => Blueprints.FromComponents(UoaDesignFile.Load(design)));
+ sources.Add(source);
+ designOf[source] = design;
+ }
+
+ if (BlueprintSaveDialog.Run(FindForm(), sources, out string showGroup, "One group per design category (designs without one go to the group above)",
+ s => CategoryGroup(designOf[s])) is { } result)
+ {
+ _info.Text = BlueprintSaveDialog.Describe(result, showGroup);
+ BlueprintsSaved?.Invoke(showGroup);
+ }
+ }
+
+ /// The group a design goes to by its category: "Category - Subsection", empty without a category.
+ private static string CategoryGroup(UoaDesign design)
+ {
+ string category = design.Category?.Trim() ?? string.Empty;
+ string subsection = design.Subsection?.Trim() ?? string.Empty;
+ if (category.Length == 0)
+ {
+ return subsection;
+ }
+
+ return Blueprints.SafeName(subsection.Length == 0 ? category : $"{category} - {subsection}");
+ }
+
private void PreviewTimer_Tick(object sender, EventArgs e)
{
_previewTimer.Stop();
@@ -132,6 +172,9 @@ private void PreviewTimer_Tick(object sender, EventArgs e)
/// Place the design into the multi being edited.
public event Action PlaceRequested;
+ /// Designs were saved as blueprints; carries the group to show (null for all).
+ public event Action BlueprintsSaved;
+
private List SelectedDesigns
{
get
@@ -307,6 +350,8 @@ private void OnSelectionChanged()
// Placing is a paste under the cursor: one design at a time.
_place.Enabled = count == 1;
+ _saveBlueprints.Enabled = design != null;
+ _saveBlueprints.Text = count > 1 ? $"Save {count} as blueprints..." : "Save as blueprint...";
if (design == null)
{
SetPreview(null);
@@ -341,7 +386,7 @@ private void ShowPreview()
try
{
MultiComponentList multi = UoaDesignFile.Load(design);
- SetPreview(multi == MultiComponentList.Empty ? null : Prefabs.Thumbnail(ToTiles(multi), _previewSize));
+ SetPreview(multi == MultiComponentList.Empty ? null : Blueprints.Thumbnail(ToTiles(multi), _previewSize));
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
diff --git a/UoFiddler.Plugin.MultiEditor/UI/DocumentIO.cs b/UoFiddler.Plugin.MultiEditor/UI/DocumentIO.cs
index b2b5ebbd..e0d16f7c 100644
--- a/UoFiddler.Plugin.MultiEditor/UI/DocumentIO.cs
+++ b/UoFiddler.Plugin.MultiEditor/UI/DocumentIO.cs
@@ -51,9 +51,10 @@ private static readonly (Multis.ImportType Type, string Extension, string Descri
///
/// Lists the client's multis (multi.mul and the UOP collection when present) and returns the chosen one.
- /// Null when cancelled.
+ /// Null when cancelled. The list can also save the chosen multis as blueprints, after which
+ /// gets the group to show (null for all).
///
- public static List OpenFromClient(IWin32Window owner)
+ public static List OpenFromClient(IWin32Window owner, Action blueprintsSaved = null)
{
Dictionary names = LoadNames();
var entries = new List();
@@ -94,15 +95,15 @@ public static List OpenFromClient(IWin32Window owner)
return null;
}
- return Pick(owner, "Open multi from client", entries, true, true).ConvertAll(p => new OpenedMulti(p.Components, p.Name, p.Id));
+ return Pick(owner, "Open multi from client", entries, true, true, SaveEntriesAction(blueprintsSaved))
+ .ConvertAll(p => new OpenedMulti(p.Components, p.Name, p.Id));
}
///
- /// Asks for one or more files of a supported import type and reads them. A file that fails
- /// is skipped and listed in the result; the multis of files that hold several are chosen
- /// from a list. Null when cancelled.
+ /// Asks for one or more files of a supported import type. Null when cancelled. Formats share
+ /// extensions, so the type is the selected filter entry's, for every file.
///
- public static ImportResult ImportFromFile(IWin32Window owner)
+ private static (string[] Files, Multis.ImportType Type)? AskImportFiles(IWin32Window owner, string title)
{
var filters = new List();
foreach ((_, string description, string pattern) in MultiFileImport.Formats)
@@ -112,21 +113,74 @@ public static ImportResult ImportFromFile(IWin32Window owner)
using var dialog = new OpenFileDialog
{
- Title = "Import multis from files",
+ Title = title,
Multiselect = true,
CheckFileExists = true,
Filter = string.Join("|", filters),
FilterIndex = 1
};
- if (dialog.ShowDialog(owner) != DialogResult.OK)
+ return dialog.ShowDialog(owner) == DialogResult.OK
+ ? (dialog.FileNames, MultiFileImport.Formats[dialog.FilterIndex - 1].Type)
+ : null;
+ }
+
+ /// A multi read from a file, as a blueprint with its name; its items are read when it is written.
+ public static BlueprintSource BlueprintFromImport(ImportedMulti multi)
+ {
+ return new BlueprintSource(multi.Name, multi.TileCount, () => Blueprints.FromComponents(multi.Components));
+ }
+
+ /// A picked row as a blueprint, with the name the list shows for it.
+ private static BlueprintSource BlueprintFromEntry(MultiPickEntry entry)
+ {
+ return new BlueprintSource(entry.Name, entry.Components.SortedTiles?.Length ?? 0, () => Blueprints.FromComponents(entry.Components));
+ }
+
+ /// The pick list's "Save as blueprints" action; null (no button) without a callback.
+ private static Func, string> SaveEntriesAction(Action blueprintsSaved, Func fileOf = null)
+ {
+ if (blueprintsSaved == null)
{
return null;
}
- // Formats share extensions, so the type is the selected filter entry's, for every file.
- Multis.ImportType type = MultiFileImport.Formats[dialog.FilterIndex - 1].Type;
- string[] files = dialog.FileNames;
+ return (dialog, picked) =>
+ {
+ List sources = picked.ConvertAll(BlueprintFromEntry);
+ var entryOf = new Dictionary();
+ for (int i = 0; i < sources.Count; ++i)
+ {
+ entryOf[sources[i]] = picked[i];
+ }
+
+ BlueprintSaveResult result = fileOf == null
+ ? BlueprintSaveDialog.Run(dialog, sources, out string showGroup)
+ : BlueprintSaveDialog.Run(dialog, sources, out showGroup, "One group per file", s => fileOf(entryOf[s]));
+ if (result == null)
+ {
+ return null;
+ }
+
+ blueprintsSaved(showGroup);
+ return BlueprintSaveDialog.Describe(result, showGroup);
+ };
+ }
+
+ ///
+ /// Asks for one or more files of a supported import type and reads them. A file that fails
+ /// is skipped and listed in the result; the multis of files that hold several are chosen
+ /// from a list, which can also save them as blueprints. Null when cancelled.
+ ///
+ public static ImportResult ImportFromFile(IWin32Window owner, Action blueprintsSaved = null)
+ {
+ if (AskImportFiles(owner, "Import multis from files") is not { } picked)
+ {
+ return null;
+ }
+
+ Multis.ImportType type = picked.Type;
+ string[] files = picked.Files;
var opened = new List();
var failures = new List();
var choices = new List();
@@ -181,16 +235,21 @@ public static ImportResult ImportFromFile(IWin32Window owner)
if (choices.Count > 0)
{
string title = files.Length == 1 ? $"Choose multis from {Path.GetFileName(files[0])}" : "Choose multis to import";
- opened.AddRange(Pick(owner, title, choices, files.Length > 1, false).ConvertAll(p => new OpenedMulti(p.Components, p.Name, null)));
+ opened.AddRange(Pick(owner, title, choices, files.Length > 1, false,
+ SaveEntriesAction(blueprintsSaved, files.Length > 1 ? e => Path.GetFileNameWithoutExtension(e.Source) : null))
+ .ConvertAll(p => new OpenedMulti(p.Components, p.Name, null)));
}
return new ImportResult(opened, failures);
}
- /// Lists the multis in every supported file under a folder and returns the chosen ones. Null when cancelled.
- public static ImportResult ImportFromFolder(IWin32Window owner)
+ ///
+ /// Lists the multis in every supported file under a folder and returns the chosen ones. Null when cancelled.
+ /// The list can also save the chosen multis as blueprints.
+ ///
+ public static ImportResult ImportFromFolder(IWin32Window owner, Action blueprintsSaved = null)
{
- using var dialog = new FolderImportDialog();
+ using var dialog = new FolderImportDialog(blueprintsSaved);
return dialog.ShowDialog(owner) == DialogResult.OK ? new ImportResult(dialog.Opened, dialog.Failures) : null;
}
@@ -404,9 +463,10 @@ public static List ExtractFromMap(IWin32Window owner, Map map, Po
}
/// The rows picked in the list (several with Ctrl or Shift); empty when cancelled.
- private static List Pick(IWin32Window owner, string title, List entries, bool showSource, bool clientSources)
+ private static List Pick(IWin32Window owner, string title, List entries, bool showSource, bool clientSources,
+ Func, string> saveBlueprints = null)
{
- using var dialog = new DocumentIOPickDialog(title, entries, showSource, clientSources);
+ using var dialog = new DocumentIOPickDialog(title, entries, showSource, clientSources, saveBlueprints);
return dialog.ShowDialog(owner) == DialogResult.OK ? new List(dialog.SelectedEntries) : new List();
}
diff --git a/UoFiddler.Plugin.MultiEditor/UI/DocumentIOPickDialog.Designer.cs b/UoFiddler.Plugin.MultiEditor/UI/DocumentIOPickDialog.Designer.cs
index b08c9c8d..e1efacac 100644
--- a/UoFiddler.Plugin.MultiEditor/UI/DocumentIOPickDialog.Designer.cs
+++ b/UoFiddler.Plugin.MultiEditor/UI/DocumentIOPickDialog.Designer.cs
@@ -37,6 +37,7 @@ private void InitializeComponent()
_preview = new System.Windows.Forms.PictureBox();
_info = new System.Windows.Forms.Label();
_ok = new System.Windows.Forms.Button();
+ _saveBlueprints = new System.Windows.Forms.Button();
cancel = new System.Windows.Forms.Button();
sourceAll = new System.Windows.Forms.RadioButton();
sourceMul = new System.Windows.Forms.RadioButton();
@@ -122,6 +123,18 @@ private void InitializeComponent()
_ok.UseVisualStyleBackColor = true;
_ok.Click += Ok_Click;
//
+ // _saveBlueprints
+ //
+ _saveBlueprints.Anchor = System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right;
+ _saveBlueprints.Location = new System.Drawing.Point(445, 425);
+ _saveBlueprints.Name = "_saveBlueprints";
+ _saveBlueprints.Size = new System.Drawing.Size(145, 23);
+ _saveBlueprints.TabIndex = 10;
+ _saveBlueprints.Text = "Save as blueprints...";
+ _saveBlueprints.UseVisualStyleBackColor = true;
+ _saveBlueprints.Visible = false;
+ _saveBlueprints.Click += SaveBlueprints_Click;
+ //
// cancel
//
cancel.Anchor = System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right;
@@ -183,6 +196,7 @@ private void InitializeComponent()
Controls.Add(_preview);
Controls.Add(_info);
Controls.Add(_ok);
+ Controls.Add(_saveBlueprints);
Controls.Add(cancel);
Controls.Add(sourceAll);
Controls.Add(sourceMul);
@@ -209,6 +223,7 @@ private void InitializeComponent()
private System.Windows.Forms.PictureBox _preview;
private System.Windows.Forms.Label _info;
private System.Windows.Forms.Button _ok;
+ private System.Windows.Forms.Button _saveBlueprints;
private System.Windows.Forms.Button cancel;
private System.Windows.Forms.RadioButton sourceAll;
private System.Windows.Forms.RadioButton sourceMul;
diff --git a/UoFiddler.Plugin.MultiEditor/UI/DocumentIOPickDialog.cs b/UoFiddler.Plugin.MultiEditor/UI/DocumentIOPickDialog.cs
index e0853de5..130be268 100644
--- a/UoFiddler.Plugin.MultiEditor/UI/DocumentIOPickDialog.cs
+++ b/UoFiddler.Plugin.MultiEditor/UI/DocumentIOPickDialog.cs
@@ -27,16 +27,24 @@ internal sealed partial class DocumentIOPickDialog : Form
{
private readonly List _entries;
private readonly List _sourceButtons = new List();
+ private readonly Func, string> _saveBlueprintsAction;
private string _sourceFilter;
/// Shows the File column: which file each multi comes from.
/// Adds the All / multi.mul / UOP buttons, for the client's multis.
- public DocumentIOPickDialog(string title, List entries, bool showSource, bool clientSources)
+ ///
+ /// Adds a "Save as blueprints" button that hands it the selected rows; it returns the text
+ /// to show afterwards, or null when nothing was saved. The dialog stays open.
+ ///
+ public DocumentIOPickDialog(string title, List entries, bool showSource, bool clientSources,
+ Func, string> saveBlueprints = null)
{
_entries = entries;
+ _saveBlueprintsAction = saveBlueprints;
InitializeComponent();
Text = title;
+ _saveBlueprints.Visible = saveBlueprints != null;
// Which file the multis come from, as push buttons: all, multi.mul or the UOP collection.
// A client without MultiCollection.uop (or without multi.mul) has that button greyed out.
@@ -151,6 +159,15 @@ private void Ok_Click(object sender, EventArgs e)
Accept();
}
+ private void SaveBlueprints_Click(object sender, EventArgs e)
+ {
+ List picked = Picked();
+ if (picked.Count > 0 && _saveBlueprintsAction(this, picked) is { } done)
+ {
+ _info.Text = done;
+ }
+ }
+
private void Populate()
{
string text = _filter.Text.Trim();
@@ -205,7 +222,7 @@ private void ShowPreview()
old?.Dispose();
MultiPickEntry entry = Current();
- _ok.Enabled = entry != null;
+ _ok.Enabled = _saveBlueprints.Enabled = entry != null;
if (entry == null)
{
_info.Text = string.Empty;
@@ -232,22 +249,29 @@ private MultiPickEntry Current()
return _list.SelectedItems.Count > 0 ? _list.SelectedItems[0].Tag as MultiPickEntry : null;
}
- private void Accept()
+ private List Picked()
{
- if (_list.SelectedItems.Count == 0)
- {
- return;
- }
-
- SelectedEntries.Clear();
+ var picked = new List();
foreach (ListViewItem item in _list.SelectedItems)
{
if (item.Tag is MultiPickEntry entry)
{
- SelectedEntries.Add(entry);
+ picked.Add(entry);
}
}
+ return picked;
+ }
+
+ private void Accept()
+ {
+ if (_list.SelectedItems.Count == 0)
+ {
+ return;
+ }
+
+ SelectedEntries.Clear();
+ SelectedEntries.AddRange(Picked());
DialogResult = DialogResult.OK;
Close();
}
diff --git a/UoFiddler.Plugin.MultiEditor/UI/EditorSettings.cs b/UoFiddler.Plugin.MultiEditor/UI/EditorSettings.cs
index 5abf696b..5b1006ca 100644
--- a/UoFiddler.Plugin.MultiEditor/UI/EditorSettings.cs
+++ b/UoFiddler.Plugin.MultiEditor/UI/EditorSettings.cs
@@ -84,6 +84,12 @@ internal sealed class EditorSettings
/// Whether Import from folder also scans the folders below.
public bool ImportRecursive { get; set; } = true;
+ ///
+ /// Blueprint groups shown as buttons beside the group drop-down, joined by '|' (a character
+ /// folder names cannot hold).
+ ///
+ public string FavoriteBlueprintGroups { get; set; } = string.Empty;
+
/// Height of the pieces list under the House page's styles.
public int HousePiecesHeight { get; set; } = 250;
diff --git a/UoFiddler.Plugin.MultiEditor/UI/FolderImportDialog.Designer.cs b/UoFiddler.Plugin.MultiEditor/UI/FolderImportDialog.Designer.cs
index 1de665f5..50174df9 100644
--- a/UoFiddler.Plugin.MultiEditor/UI/FolderImportDialog.Designer.cs
+++ b/UoFiddler.Plugin.MultiEditor/UI/FolderImportDialog.Designer.cs
@@ -47,6 +47,7 @@ private void InitializeComponent()
_info = new System.Windows.Forms.Label();
_status = new System.Windows.Forms.Label();
_ok = new System.Windows.Forms.Button();
+ _saveBlueprints = new System.Windows.Forms.Button();
cancel = new System.Windows.Forms.Button();
_previewTimer = new System.Windows.Forms.Timer(components);
((System.ComponentModel.ISupportInitialize)_preview).BeginInit();
@@ -207,9 +208,22 @@ private void InitializeComponent()
_status.AutoEllipsis = true;
_status.Location = new System.Drawing.Point(10, 524);
_status.Name = "_status";
- _status.Size = new System.Drawing.Size(700, 18);
+ _status.Size = new System.Drawing.Size(540, 18);
_status.TabIndex = 11;
//
+ // _saveBlueprints
+ //
+ _saveBlueprints.Anchor = System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right;
+ _saveBlueprints.Enabled = false;
+ _saveBlueprints.Location = new System.Drawing.Point(560, 520);
+ _saveBlueprints.Name = "_saveBlueprints";
+ _saveBlueprints.Size = new System.Drawing.Size(155, 25);
+ _saveBlueprints.TabIndex = 14;
+ _saveBlueprints.Text = "Save as blueprints...";
+ _saveBlueprints.UseVisualStyleBackColor = true;
+ _saveBlueprints.Visible = false;
+ _saveBlueprints.Click += SaveBlueprints_Click;
+ //
// _ok
//
_ok.Anchor = System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right;
@@ -256,6 +270,7 @@ private void InitializeComponent()
Controls.Add(_info);
Controls.Add(_status);
Controls.Add(_ok);
+ Controls.Add(_saveBlueprints);
Controls.Add(cancel);
FormBorderStyle = System.Windows.Forms.FormBorderStyle.Sizable;
MinimizeBox = false;
@@ -289,6 +304,7 @@ private void InitializeComponent()
private System.Windows.Forms.Label _info;
private System.Windows.Forms.Label _status;
private System.Windows.Forms.Button _ok;
+ private System.Windows.Forms.Button _saveBlueprints;
private System.Windows.Forms.Button cancel;
private System.Windows.Forms.Timer _previewTimer;
}
diff --git a/UoFiddler.Plugin.MultiEditor/UI/FolderImportDialog.cs b/UoFiddler.Plugin.MultiEditor/UI/FolderImportDialog.cs
index aa65b283..23c2e975 100644
--- a/UoFiddler.Plugin.MultiEditor/UI/FolderImportDialog.cs
+++ b/UoFiddler.Plugin.MultiEditor/UI/FolderImportDialog.cs
@@ -17,6 +17,7 @@
using System.Threading.Tasks;
using System.Windows.Forms;
using Ultima;
+using UoFiddler.Plugin.MultiEditor.Classes;
namespace UoFiddler.Plugin.MultiEditor.UI
{
@@ -65,10 +66,21 @@ private sealed record FormatChoice(string Key, string Text)
private int _multiCount;
private int _failedCount;
private bool _bulkSelecting;
+ private readonly Action _blueprintsSaved;
public FolderImportDialog()
+ : this(null)
+ {
+ }
+
+ ///
+ /// Adds a "Save as blueprints" button; after a save it gets the group to show (null for all).
+ ///
+ public FolderImportDialog(Action blueprintsSaved)
{
InitializeComponent();
+ _blueprintsSaved = blueprintsSaved;
+ _saveBlueprints.Visible = blueprintsSaved != null;
EditorSettings settings = EditorSettings.Current;
_folder.Text = settings.ImportFolder ?? string.Empty;
@@ -255,6 +267,31 @@ private void Ok_Click(object sender, EventArgs e)
Accept();
}
+ /// Saves the selected multis as blueprints, optionally one group per file; the dialog stays open.
+ private void SaveBlueprints_Click(object sender, EventArgs e)
+ {
+ List rows = SelectedRows().Where(r => r.Multi != null).ToList();
+ if (rows.Count == 0)
+ {
+ return;
+ }
+
+ var fileOf = new Dictionary();
+ var sources = new List();
+ foreach (Row row in rows)
+ {
+ BlueprintSource source = DocumentIO.BlueprintFromImport(row.Multi);
+ sources.Add(source);
+ fileOf[source] = Path.GetFileNameWithoutExtension(row.File);
+ }
+
+ if (BlueprintSaveDialog.Run(this, sources, out string showGroup, "One group per file", s => fileOf[s]) is { } result)
+ {
+ _blueprintsSaved(showGroup);
+ _status.Text = BlueprintSaveDialog.Describe(result, showGroup);
+ }
+ }
+
private void PreviewTimer_Tick(object sender, EventArgs e)
{
_previewTimer.Stop();
@@ -531,7 +568,7 @@ private void UpdateSelection()
List rows = SelectedRows();
int openable = rows.Count(r => r.Multi != null);
- _ok.Enabled = openable > 0;
+ _ok.Enabled = _saveBlueprints.Enabled = openable > 0;
_ok.Text = openable > 1 ? $"Open {openable:N0}" : "Open";
if (rows.Count > 1)
diff --git a/UoFiddler.Plugin.MultiEditor/UI/MultiEditorControl.cs b/UoFiddler.Plugin.MultiEditor/UI/MultiEditorControl.cs
index deb6ac3d..ee97834f 100644
--- a/UoFiddler.Plugin.MultiEditor/UI/MultiEditorControl.cs
+++ b/UoFiddler.Plugin.MultiEditor/UI/MultiEditorControl.cs
@@ -319,7 +319,7 @@ private void EnsureBuilt()
Session.SwapSelectionToHouseStyle();
}
};
- _browser.PrefabChosen += tiles =>
+ _browser.BlueprintChosen += tiles =>
{
Session?.BeginPaste(tiles);
_canvas.Focus();
@@ -443,7 +443,7 @@ private EditorCommand Command(string group, string text, Action execute, Keys sh
});
}
- private EditorCommand _new, _open, _import, _importFolder, _saveClient, _export, _savePrefab, _resize, _close, _closeAll;
+ private EditorCommand _new, _open, _import, _importFolder, _saveClient, _export, _saveBlueprint, _resize, _close, _closeAll;
private EditorCommand _undo, _redo, _cut, _copy, _paste, _delete;
private EditorCommand _selectAll, _invert, _selectConnected, _selectSameId, _replaceId;
private EditorCommand _rotateLeft, _rotateRight, _mirrorX, _mirrorY, _swapStyle, _hide, _showHidden;
@@ -478,7 +478,7 @@ private void DefineCommands()
_importFolder = Command("File", "Import from folder...", ImportFolder, Keys.Control | Keys.Shift | Keys.I);
_saveClient = Command("File", "Save to client...", SaveToClient, Keys.Control | Keys.S, hasDoc, glyph: Glyphs.Save);
_export = Command("File", "Export file...", ExportFile, Keys.Control | Keys.E, hasDoc);
- _savePrefab = Command("File", "Save selection as blueprint...", SavePrefab, can: hasSelection);
+ _saveBlueprint = Command("File", "Save as blueprint...", SaveBlueprint, can: hasDoc);
_savePattern = Command("File", "Save selection as floor pattern...", SaveFloorPattern, can: hasSelection);
_resize = Command("File", "Resize multi...", ResizePrompt, can: hasDoc);
_settingsCommand = Command("File", "Settings...", ShowSettings);
@@ -711,16 +711,30 @@ private void ReplaceIdPrompt()
_statusHint.Text = $"Replaced {count} tile(s)" + (selectionOnly ? " in the selection" : string.Empty);
}
- private void SavePrefab()
+ /// Saves the selection as a blueprint, or the whole design when nothing is selected.
+ private void SaveBlueprint()
{
- List tiles = Session.SelectionAsClipboard();
- string name = tiles != null ? Prompt.Text(this, "Save blueprint", "Blueprint name:") : null;
- if (name == null)
+ bool whole = Session.Selection.Count == 0;
+ List tiles = whole ? Session.DesignAsClipboard() : Session.SelectionAsClipboard();
+ if (tiles == null)
+ {
+ return;
+ }
+
+ if (tiles.Count > Blueprints.LargeBlueprintTiles &&
+ MessageBox.Show(this, $"This blueprint has {tiles.Count:N0} tiles. Large blueprints are slow to place and to preview.\r\nSave it anyway?",
+ "Save blueprint", MessageBoxButtons.OKCancel, MessageBoxIcon.Question) != DialogResult.OK)
+ {
+ return;
+ }
+
+ string name = Prompt.Text(this, whole ? "Save design as blueprint" : "Save blueprint", "Blueprint name:", whole ? Session.Title : string.Empty);
+ if (string.IsNullOrWhiteSpace(name))
{
return;
}
- _browser.SaveBlueprint(name, tiles);
+ _browser.SaveBlueprint(name.Trim(), tiles);
}
private void DrawWithSelected()
@@ -755,7 +769,7 @@ private void BuildCanvasMenu()
}
_canvasMenu.Items.Add(new ToolStripSeparator());
- foreach (EditorCommand command in new[] { _selectConnected, _selectSameId, _selectAll, _savePrefab, _savePattern })
+ foreach (EditorCommand command in new[] { _selectConnected, _selectSameId, _selectAll, _saveBlueprint, _savePattern })
{
_canvasMenu.Items.Add(_commands.MenuItem(command));
}
@@ -1133,7 +1147,7 @@ private void BuildMainStrip()
{
ToolStrip strip = _mainStrip;
- Menu(_fileMenu, _new, _open, _import, _importFolder, null, _saveClient, _export, _savePrefab, _savePattern, null, _resize, _settingsCommand, null, _close, _closeAll);
+ Menu(_fileMenu, _new, _open, _import, _importFolder, null, _saveClient, _export, _saveBlueprint, _savePattern, null, _resize, _settingsCommand, null, _close, _closeAll);
Menu(_editMenu, _undo, _redo, null, _cut, _copy, _paste, _delete, null,
_rotateLeft, _rotateRight, _mirrorX, _mirrorY, _swapStyle, null, _zUp, _zDown, _hide, _showHidden);
Menu(_selectMenu, _selectAll, _invert, _selectConnected, _selectSameId, null, _replaceId);
@@ -1393,17 +1407,23 @@ private void NewDocument(int width, int height, string title, int firstFloorZ =
private void OpenFromClient()
{
- AddDocuments(DocumentIO.OpenFromClient(this));
+ AddDocuments(DocumentIO.OpenFromClient(this, BlueprintsSaved));
}
private void ImportFile()
{
- AddImported(DocumentIO.ImportFromFile(this));
+ AddImported(DocumentIO.ImportFromFile(this, BlueprintsSaved));
}
private void ImportFolder()
{
- AddImported(DocumentIO.ImportFromFolder(this));
+ AddImported(DocumentIO.ImportFromFolder(this, BlueprintsSaved));
+ }
+
+ /// Blueprints were written by an open or import dialog: the Blueprints page shows the group (null for all).
+ private void BlueprintsSaved(string showGroup)
+ {
+ _browser.ReloadBlueprints(showGroup);
}
/// Opens what an import read, then lists what it could not.
diff --git a/UoFiddler.Plugin.MultiEditor/UI/TileBrowserPanel.Designer.cs b/UoFiddler.Plugin.MultiEditor/UI/TileBrowserPanel.Designer.cs
index c067bf5f..9fc20f2a 100644
--- a/UoFiddler.Plugin.MultiEditor/UI/TileBrowserPanel.Designer.cs
+++ b/UoFiddler.Plugin.MultiEditor/UI/TileBrowserPanel.Designer.cs
@@ -59,16 +59,16 @@ private void InitializeComponent()
_houseKindBar = new System.Windows.Forms.ToolStrip();
_foundationButton = new System.Windows.Forms.ToolStripButton();
_patternsButton = new System.Windows.Forms.ToolStripButton();
- _prefabsPage = new System.Windows.Forms.TabPage();
- _prefabList = new System.Windows.Forms.ListView();
- _prefabIcons = new System.Windows.Forms.ImageList(components);
- _prefabGroupBar = new System.Windows.Forms.ToolStrip();
+ _blueprintsPage = new System.Windows.Forms.TabPage();
+ _blueprintList = new System.Windows.Forms.ListView();
+ _blueprintIcons = new System.Windows.Forms.ImageList(components);
+ _blueprintGroupBar = new System.Windows.Forms.ToolStrip();
_designsPage = new System.Windows.Forms.TabPage();
_designLibrary = new DesignLibraryPanel();
_toolTip = new System.Windows.Forms.ToolTip(components);
_searchTimer = new System.Windows.Forms.Timer(components);
_tileMenu = new System.Windows.Forms.ContextMenuStrip(components);
- _prefabMenu = new System.Windows.Forms.ContextMenuStrip(components);
+ _blueprintMenu = new System.Windows.Forms.ContextMenuStrip(components);
_filterBar.SuspendLayout();
_tabs.SuspendLayout();
_tilesPage.SuspendLayout();
@@ -81,7 +81,7 @@ private void InitializeComponent()
_recentBar.SuspendLayout();
_housePage.SuspendLayout();
_houseKindBar.SuspendLayout();
- _prefabsPage.SuspendLayout();
+ _blueprintsPage.SuspendLayout();
_designsPage.SuspendLayout();
SuspendLayout();
//
@@ -144,7 +144,7 @@ private void InitializeComponent()
_tabs.Controls.Add(_favoritesPage);
_tabs.Controls.Add(_recentPage);
_tabs.Controls.Add(_housePage);
- _tabs.Controls.Add(_prefabsPage);
+ _tabs.Controls.Add(_blueprintsPage);
_tabs.Controls.Add(_designsPage);
_tabs.Dock = System.Windows.Forms.DockStyle.Fill;
_tabs.Location = new System.Drawing.Point(0, 52);
@@ -387,44 +387,44 @@ private void InitializeComponent()
_patternsButton.ToolTipText = "Bordered floors: corner, side and fill pieces laid as one. Save your own from a selection (File menu).";
_patternsButton.Click += PatternsButton_Click;
//
- // _prefabsPage
- //
- _prefabsPage.Controls.Add(_prefabList);
- _prefabsPage.Controls.Add(_prefabGroupBar);
- _prefabsPage.Location = new System.Drawing.Point(4, 44);
- _prefabsPage.Name = "_prefabsPage";
- _prefabsPage.Size = new System.Drawing.Size(192, 52);
- _prefabsPage.TabIndex = 4;
- _prefabsPage.Text = "Blueprints";
- //
- // _prefabList
- //
- _prefabList.Dock = System.Windows.Forms.DockStyle.Fill;
- _prefabList.LargeImageList = _prefabIcons;
- _prefabList.Location = new System.Drawing.Point(0, 0);
- _prefabList.MultiSelect = false;
- _prefabList.Name = "_prefabList";
- _prefabList.ShowItemToolTips = true;
- _prefabList.Size = new System.Drawing.Size(192, 52);
- _prefabList.TabIndex = 0;
- _prefabList.UseCompatibleStateImageBehavior = false;
- _prefabList.KeyDown += PrefabList_KeyDown;
- _prefabList.MouseClick += PrefabList_MouseClick;
- //
- // _prefabIcons
- //
- _prefabIcons.ColorDepth = System.Windows.Forms.ColorDepth.Depth32Bit;
- _prefabIcons.ImageSize = new System.Drawing.Size(80, 80);
- _prefabIcons.TransparentColor = System.Drawing.Color.Transparent;
- //
- // _prefabGroupBar
- //
- _prefabGroupBar.GripStyle = System.Windows.Forms.ToolStripGripStyle.Hidden;
- _prefabGroupBar.LayoutStyle = System.Windows.Forms.ToolStripLayoutStyle.Flow;
- _prefabGroupBar.Location = new System.Drawing.Point(0, 0);
- _prefabGroupBar.Name = "_prefabGroupBar";
- _prefabGroupBar.Size = new System.Drawing.Size(192, 0);
- _prefabGroupBar.TabIndex = 1;
+ // _blueprintsPage
+ //
+ _blueprintsPage.Controls.Add(_blueprintList);
+ _blueprintsPage.Controls.Add(_blueprintGroupBar);
+ _blueprintsPage.Location = new System.Drawing.Point(4, 44);
+ _blueprintsPage.Name = "_blueprintsPage";
+ _blueprintsPage.Size = new System.Drawing.Size(192, 52);
+ _blueprintsPage.TabIndex = 4;
+ _blueprintsPage.Text = "Blueprints";
+ //
+ // _blueprintList
+ //
+ _blueprintList.Dock = System.Windows.Forms.DockStyle.Fill;
+ _blueprintList.LargeImageList = _blueprintIcons;
+ _blueprintList.Location = new System.Drawing.Point(0, 0);
+ _blueprintList.MultiSelect = false;
+ _blueprintList.Name = "_blueprintList";
+ _blueprintList.ShowItemToolTips = true;
+ _blueprintList.Size = new System.Drawing.Size(192, 52);
+ _blueprintList.TabIndex = 0;
+ _blueprintList.UseCompatibleStateImageBehavior = false;
+ _blueprintList.KeyDown += BlueprintList_KeyDown;
+ _blueprintList.MouseClick += BlueprintList_MouseClick;
+ //
+ // _blueprintIcons
+ //
+ _blueprintIcons.ColorDepth = System.Windows.Forms.ColorDepth.Depth32Bit;
+ _blueprintIcons.ImageSize = new System.Drawing.Size(80, 80);
+ _blueprintIcons.TransparentColor = System.Drawing.Color.Transparent;
+ //
+ // _blueprintGroupBar
+ //
+ _blueprintGroupBar.GripStyle = System.Windows.Forms.ToolStripGripStyle.Hidden;
+ _blueprintGroupBar.LayoutStyle = System.Windows.Forms.ToolStripLayoutStyle.Flow;
+ _blueprintGroupBar.Location = new System.Drawing.Point(0, 0);
+ _blueprintGroupBar.Name = "_blueprintGroupBar";
+ _blueprintGroupBar.Size = new System.Drawing.Size(192, 0);
+ _blueprintGroupBar.TabIndex = 1;
//
// _designsPage
//
@@ -444,6 +444,7 @@ private void InitializeComponent()
_designLibrary.TabIndex = 0;
_designLibrary.OpenRequested += DesignLibrary_OpenRequested;
_designLibrary.PlaceRequested += DesignLibrary_PlaceRequested;
+ _designLibrary.BlueprintsSaved += DesignLibrary_BlueprintsSaved;
//
// _searchTimer
//
@@ -455,10 +456,10 @@ private void InitializeComponent()
_tileMenu.Name = "_tileMenu";
_tileMenu.Size = new System.Drawing.Size(61, 4);
//
- // _prefabMenu
+ // _blueprintMenu
//
- _prefabMenu.Name = "_prefabMenu";
- _prefabMenu.Size = new System.Drawing.Size(61, 4);
+ _blueprintMenu.Name = "_blueprintMenu";
+ _blueprintMenu.Size = new System.Drawing.Size(61, 4);
//
// TileBrowserPanel
//
@@ -486,8 +487,8 @@ private void InitializeComponent()
_housePage.PerformLayout();
_houseKindBar.ResumeLayout(false);
_houseKindBar.PerformLayout();
- _prefabsPage.ResumeLayout(false);
- _prefabsPage.PerformLayout();
+ _blueprintsPage.ResumeLayout(false);
+ _blueprintsPage.PerformLayout();
_designsPage.ResumeLayout(false);
ResumeLayout(false);
PerformLayout();
@@ -525,15 +526,15 @@ private void InitializeComponent()
private System.Windows.Forms.ToolStrip _houseKindBar;
private System.Windows.Forms.ToolStripButton _foundationButton;
private System.Windows.Forms.ToolStripButton _patternsButton;
- private System.Windows.Forms.TabPage _prefabsPage;
- private System.Windows.Forms.ListView _prefabList;
- private System.Windows.Forms.ImageList _prefabIcons;
- private System.Windows.Forms.ToolStrip _prefabGroupBar;
+ private System.Windows.Forms.TabPage _blueprintsPage;
+ private System.Windows.Forms.ListView _blueprintList;
+ private System.Windows.Forms.ImageList _blueprintIcons;
+ private System.Windows.Forms.ToolStrip _blueprintGroupBar;
private System.Windows.Forms.TabPage _designsPage;
private DesignLibraryPanel _designLibrary;
private System.Windows.Forms.ToolTip _toolTip;
private System.Windows.Forms.Timer _searchTimer;
private System.Windows.Forms.ContextMenuStrip _tileMenu;
- private System.Windows.Forms.ContextMenuStrip _prefabMenu;
+ private System.Windows.Forms.ContextMenuStrip _blueprintMenu;
}
}
diff --git a/UoFiddler.Plugin.MultiEditor/UI/TileBrowserPanel.cs b/UoFiddler.Plugin.MultiEditor/UI/TileBrowserPanel.cs
index 55a92787..c81ebcfc 100644
--- a/UoFiddler.Plugin.MultiEditor/UI/TileBrowserPanel.cs
+++ b/UoFiddler.Plugin.MultiEditor/UI/TileBrowserPanel.cs
@@ -24,7 +24,7 @@ namespace UoFiddler.Plugin.MultiEditor.UI
{
///
/// The editor's tile picker: one search box over pages of tiles (the multieditor.xml tree),
- /// favorites, recently placed tiles, house part styles and prefabs. Data files are read on
+ /// favorites, recently placed tiles, house part styles and blueprints. Data files are read on
/// first show, and a missing file only leaves its page empty.
///
internal sealed partial class TileBrowserPanel : UserControl
@@ -78,7 +78,7 @@ private static readonly (string Name, Func Matches)[] _tileTypes
private int _favoritesVersion = -1;
private int _recentVersion = -1;
private int _houseVersion = -1;
- private int _prefabsVersion = -1;
+ private int _blueprintsVersion = -1;
///
/// House styles are not passed here: they arrive through ,
@@ -140,8 +140,8 @@ public TileBrowserPanel()
/// The user picked the foundation entry of the House page.
public event Action FoundationChosen;
- /// The user activated a prefab: the shell starts a paste with these tiles.
- public event Action> PrefabChosen;
+ /// The user activated a blueprint: the shell starts a paste with these tiles.
+ public event Action> BlueprintChosen;
///
/// The House page's swap button: swap the selected pieces to the same columns of this style
@@ -187,12 +187,12 @@ public void RecordRecent(ushort id)
}
}
- /// Re-reads the prefab folder.
- public void RefreshPrefabs()
+ /// Re-reads the blueprint folder.
+ public void RefreshBlueprints()
{
- if (_prefabsLoaded)
+ if (_blueprintsLoaded)
{
- LoadPrefabs();
+ LoadBlueprints();
}
}
@@ -212,9 +212,9 @@ public void ReloadData()
_categoryBeforeSearch = FindCategory(beforeSearchPath);
ReloadHouseIcons();
- if (_prefabsLoaded)
+ if (_blueprintsLoaded)
{
- LoadPrefabs();
+ LoadBlueprints();
}
_filterVersion++;
@@ -286,6 +286,12 @@ private void DesignLibrary_PlaceRequested(UoaDesign design)
DesignPlaceRequested?.Invoke(design);
}
+ /// Designs were saved as blueprints: the Blueprints page shows them next time, the Designs page stays up.
+ private void DesignLibrary_BlueprintsSaved(string showGroup)
+ {
+ ReloadBlueprints(showGroup, bringToFront: false);
+ }
+
private void TilesSplit_SizeChanged(object sender, EventArgs e)
{
PlaceSplitter();
@@ -452,9 +458,9 @@ private void SearchBox_KeyDown(object sender, KeyEventArgs e)
{
_houseGallery.Focus();
}
- else if (_tabs.SelectedTab == _prefabsPage)
+ else if (_tabs.SelectedTab == _blueprintsPage)
{
- _prefabList.Focus();
+ _blueprintList.Focus();
}
e.Handled = true;
@@ -478,10 +484,10 @@ private void PickFirstResult()
{
ActivateFirstHouseNode();
}
- else if (_tabs.SelectedTab == _prefabsPage && _prefabList.Items.Count > 0)
+ else if (_tabs.SelectedTab == _blueprintsPage && _blueprintList.Items.Count > 0)
{
- _prefabList.Items[0].Selected = true;
- PlaceSelectedPrefab();
+ _blueprintList.Items[0].Selected = true;
+ PlaceSelectedBlueprint();
}
}
@@ -555,13 +561,15 @@ private void RefreshSelectedPage(bool keepScroll = false)
RebuildHouseTree();
}
}
- else if (page == _prefabsPage)
+ else if (page == _blueprintsPage)
{
- EnsurePrefabs();
- if (_prefabsVersion != _filterVersion)
+ EnsureBlueprints();
+ if (_blueprintsVersion != _filterVersion)
{
- RebuildPrefabList();
+ RebuildBlueprintList();
}
+
+ StartThumbnails();
}
// Results start at the top; only a first fill looks for the current tile.
@@ -600,7 +608,11 @@ private void UpdateStatus()
}
else
{
- list = _prefabList.Items.Count == 0 && _query == null ? "No blueprints saved yet" : $"{_prefabList.Items.Count} blueprints";
+ list = _blueprintList.Items.Count == 0 && _query == null ? "No blueprints saved yet" : $"{_blueprintList.Items.Count} blueprints";
+ if (SelectedBlueprint is { } blueprint)
+ {
+ list += $" | {blueprint.Name}: {Blueprints.FootprintText(blueprint.Footprint, true)}, {blueprint.TileCount:N0} tiles";
+ }
}
string current = _currentTile >= 0 ? $"Current 0x{_currentTile:X4} {TileBrowserThumbnails.NameOf(_currentTile)}" : string.Empty;
@@ -1657,25 +1669,55 @@ private void ActivateFirstHouseNode()
// ---------------------------------------------------------------- blueprints page
- private const int _prefabIconSize = 80;
+ private const int _blueprintIconSize = 80;
- private readonly List _prefabs = new List();
- private readonly List _prefabGroups = new List();
- private bool _prefabsLoaded;
+ private readonly List _blueprints = new List();
+ private readonly List _blueprintGroups = new List();
+ private bool _blueprintsLoaded;
/// The group shown: null for all, empty for the blueprints in no group, else a group name.
- private string _prefabGroup;
+ private string _blueprintGroup;
- /// A blueprint file. Group is the subfolder it is in, empty for the blueprint folder itself.
- private sealed record PrefabEntry(string Name, string Group, string Path, int TileCount, int ImageIndex, HashSet Ids);
+ /// Blueprints whose thumbnail is not drawn yet; they show the blank image 0 meanwhile.
+ private readonly HashSet _thumbsPending = new HashSet();
+ private Timer _thumbTimer;
+
+ ///
+ /// A blueprint file. Group is the subfolder it is in, empty for the blueprint folder itself;
+ /// ImageIndex is 0 (blank) until its thumbnail is drawn.
+ ///
+ private sealed class BlueprintEntry
+ {
+ public BlueprintEntry(string name, string group, string path, int tileCount, (int Width, int Depth, int Height) footprint,
+ HashSet ids)
+ {
+ Name = name;
+ Group = group;
+ Path = path;
+ TileCount = tileCount;
+ Footprint = footprint;
+ Ids = ids;
+ }
+
+ public string Name { get; }
+ public string Group { get; }
+ public string Path { get; }
+ public int TileCount { get; }
+
+ /// Cells along X and Y and how high it is in Z.
+ public (int Width, int Depth, int Height) Footprint { get; }
+
+ public HashSet Ids { get; }
+ public int ImageIndex { get; set; }
+ }
///
/// One click places a blueprint, like picking a tile; a right click opens its menu
/// without starting a paste.
///
- private void PrefabList_MouseClick(object sender, MouseEventArgs e)
+ private void BlueprintList_MouseClick(object sender, MouseEventArgs e)
{
- if (_prefabList.GetItemAt(e.X, e.Y) is not { } item)
+ if (_blueprintList.GetItemAt(e.X, e.Y) is not { } item)
{
return;
}
@@ -1683,53 +1725,71 @@ private void PrefabList_MouseClick(object sender, MouseEventArgs e)
item.Selected = true;
if (e.Button == MouseButtons.Left)
{
- PlaceSelectedPrefab();
+ PlaceSelectedBlueprint();
}
else if (e.Button == MouseButtons.Right)
{
- ShowPrefabMenu(e.Location);
+ ShowBlueprintMenu(e.Location);
}
}
- private void PrefabList_KeyDown(object sender, KeyEventArgs e)
+ private void BlueprintList_KeyDown(object sender, KeyEventArgs e)
{
if (e.KeyCode == Keys.Delete)
{
- DeleteSelectedPrefab();
+ DeleteSelectedBlueprint();
e.Handled = true;
}
else if (e.KeyCode == Keys.Enter)
{
- PlaceSelectedPrefab();
+ PlaceSelectedBlueprint();
e.Handled = e.SuppressKeyPress = true;
}
else if (e.KeyCode == Keys.F2)
{
- RenameSelectedPrefab();
+ RenameSelectedBlueprint();
e.Handled = true;
}
}
- private void EnsurePrefabs()
+ private void EnsureBlueprints()
{
- if (!_prefabsLoaded)
+ if (!_blueprintsLoaded)
{
- LoadPrefabs();
+ LoadBlueprints();
}
}
+ /// The group a new blueprint goes to: the one shown, empty (no group) when all are shown.
+ public string ShownBlueprintGroup => _blueprintGroup ?? string.Empty;
+
///
/// Saves tiles as a blueprint in the group being shown (no group when all are shown) and
- /// brings up the Blueprints page.
+ /// brings up the Blueprints page. A name that is taken asks whether to replace it.
///
public void SaveBlueprint(string name, List tiles)
{
EnsureLoaded();
+ string folder = Blueprints.GroupFolder(ShownBlueprintGroup);
+ string path = Path.Combine(folder, Blueprints.SafeName(name) + ".txt");
+ if (File.Exists(path))
+ {
+ DialogResult answer = MessageBox.Show(this, $"A blueprint named \"{name}\" already exists. Replace it?\n\nNo saves this one under a new name.",
+ _messageTitle, MessageBoxButtons.YesNoCancel, MessageBoxIcon.Question);
+ if (answer == DialogResult.Cancel)
+ {
+ return;
+ }
+
+ if (answer == DialogResult.No)
+ {
+ (path, name) = Blueprints.UniqueName(folder, name);
+ }
+ }
+
try
{
- string folder = GroupFolder(_prefabGroup ?? string.Empty);
- Directory.CreateDirectory(folder);
- File.WriteAllText(Path.Combine(folder, Prefabs.SafeName(name) + ".txt"), ClipboardTile.Format(tiles));
+ Blueprints.Write(path, tiles, name);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
@@ -1737,233 +1797,361 @@ public void SaveBlueprint(string name, List tiles)
return;
}
- if (_tabs.SelectedTab != _prefabsPage)
+ ReloadBlueprints(_blueprintGroup);
+ }
+
+ ///
+ /// Re-reads the blueprint folder and shows a group (null for all) on the Blueprints page,
+ /// after blueprints were written elsewhere; the page is brought up unless told not to.
+ ///
+ public void ReloadBlueprints(string showGroup, bool bringToFront = true)
+ {
+ EnsureLoaded();
+ _blueprintGroup = showGroup;
+
+ // A page never shown reads the folder when it first shows.
+ bool wasLoaded = _blueprintsLoaded;
+ if (bringToFront && _tabs.SelectedTab != _blueprintsPage)
{
- _tabs.SelectedTab = _prefabsPage;
+ _tabs.SelectedTab = _blueprintsPage;
}
- LoadPrefabs();
+ if (wasLoaded)
+ {
+ LoadBlueprints();
+ }
}
- private static string GroupFolder(string group)
- {
- return string.IsNullOrEmpty(group) ? Prefabs.Folder : Path.Combine(Prefabs.Folder, group);
- }
+ private static string GroupFolder(string group) => Blueprints.GroupFolder(group);
///
- /// Reads every blueprint file and draws its thumbnail; unreadable files are skipped. A
- /// subfolder of the blueprint folder is a group, also when it is empty.
+ /// Reads every blueprint file; unreadable files are skipped. Thumbnails are drawn afterwards,
+ /// the ones in view first. A subfolder of the blueprint folder is a group, also when it is empty.
///
- private void LoadPrefabs()
+ private void LoadBlueprints()
{
- _prefabsLoaded = true;
- _prefabs.Clear();
- _prefabGroups.Clear();
- _prefabList.Items.Clear();
- _prefabIcons.Images.Clear();
+ _blueprintsLoaded = true;
+ _blueprints.Clear();
+ _thumbsPending.Clear();
+ _blueprintGroups.Clear();
+ _blueprintList.Items.Clear();
+ _blueprintIcons.Images.Clear();
+ using (var blank = new Bitmap(_blueprintIconSize, _blueprintIconSize))
+ {
+ _blueprintIcons.Images.Add(blank);
+ }
+ bool complete = true;
try
{
- if (Directory.Exists(Prefabs.Folder))
+ if (Directory.Exists(Blueprints.Folder))
{
- AddPrefabsOf(string.Empty);
- foreach (string folder in Directory.EnumerateDirectories(Prefabs.Folder).OrderBy(f => f, StringComparer.OrdinalIgnoreCase))
+ AddBlueprintsOf(string.Empty);
+ foreach (string folder in Directory.EnumerateDirectories(Blueprints.Folder).OrderBy(f => f, StringComparer.OrdinalIgnoreCase))
{
string group = Path.GetFileName(folder);
- _prefabGroups.Add(group);
- AddPrefabsOf(group);
+ _blueprintGroups.Add(group);
+ AddBlueprintsOf(group);
}
}
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// What could be read is shown.
+ complete = false;
}
- if (!string.IsNullOrEmpty(_prefabGroup) && !_prefabGroups.Contains(_prefabGroup))
+ if (!string.IsNullOrEmpty(_blueprintGroup) && !_blueprintGroups.Contains(_blueprintGroup))
{
- _prefabGroup = null;
+ _blueprintGroup = null;
}
- RebuildPrefabGroupBar();
- RebuildPrefabList();
+ // Only a full read says a group is gone; a failed one may just have stopped early.
+ if (complete)
+ {
+ DropMissingFavoriteGroups();
+ }
+
+ RebuildBlueprintGroupBar();
+ RebuildBlueprintList();
}
- private void AddPrefabsOf(string group)
+ private void AddBlueprintsOf(string group)
{
foreach (string path in Directory.EnumerateFiles(GroupFolder(group), "*.txt").OrderBy(f => f, StringComparer.OrdinalIgnoreCase))
{
- List tiles = LoadPrefab(path);
+ (List tiles, string name) = Blueprints.Read(path);
if (tiles == null || tiles.Count == 0)
{
continue;
}
- using (Bitmap thumb = Prefabs.Thumbnail(tiles, _prefabIconSize))
- {
- _prefabIcons.Images.Add(thumb);
- }
-
- _prefabs.Add(new PrefabEntry(Path.GetFileNameWithoutExtension(path), group, path, tiles.Count,
- _prefabIcons.Images.Count - 1, tiles.Select(t => (int)t.Id).ToHashSet()));
+ var entry = new BlueprintEntry(name, group, path, tiles.Count,
+ Blueprints.Footprint(tiles), tiles.Select(t => (int)t.Id).ToHashSet());
+ _blueprints.Add(entry);
+ _thumbsPending.Add(entry);
}
}
- private static List LoadPrefab(string path)
+ private static List LoadBlueprint(string path) => Blueprints.Read(path).Tiles;
+
+ /// Starts drawing the thumbnails still missing, while the Blueprints page shows.
+ private void StartThumbnails()
{
- try
+ if (_thumbsPending.Count == 0)
{
- return File.Exists(path) ? ClipboardTile.Parse(File.ReadAllText(path)) : null;
+ return;
}
- catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+
+ if (_thumbTimer == null)
{
- return null;
+ _thumbTimer = new Timer(components) { Interval = 15 };
+ _thumbTimer.Tick += ThumbTimer_Tick;
}
+
+ _thumbTimer.Start();
}
- private void RebuildPrefabGroupBar()
+ ///
+ /// Draws missing thumbnails for a few milliseconds: the blueprints in view first, then the
+ /// rest of the list. Stops when the page is hidden or every listed one is drawn.
+ ///
+ private void ThumbTimer_Tick(object sender, EventArgs e)
{
- _prefabGroupBar.Items.Clear();
+ if (!_blueprintList.Visible || _thumbsPending.Count == 0)
+ {
+ _thumbTimer.Stop();
+ return;
+ }
- void Add(string text, string group, string tip)
+ var watch = System.Diagnostics.Stopwatch.StartNew();
+ Rectangle view = _blueprintList.ClientRectangle;
+ var inView = new List();
+ var later = new List();
+ foreach (ListViewItem item in _blueprintList.Items)
{
- int count = group == null ? _prefabs.Count : _prefabs.Count(p => p.Group == group);
- var button = new ToolStripButton($"{text} ({count})")
+ if (item.Tag is BlueprintEntry entry && _thumbsPending.Contains(entry))
{
- DisplayStyle = ToolStripItemDisplayStyle.Text,
- Checked = _prefabGroup == group,
- ToolTipText = tip,
- AutoToolTip = false,
- Margin = new Padding(1, 1, 0, 2)
- };
- button.Click += (_, _) =>
+ (item.Bounds.IntersectsWith(view) ? inView : later).Add(item);
+ }
+ }
+
+ if (inView.Count == 0 && later.Count == 0)
+ {
+ // What is left is filtered out; it is drawn when it shows again.
+ _thumbTimer.Stop();
+ return;
+ }
+
+ // Setting an item's image repaints only that item, so no BeginUpdate (it would repaint the list).
+ foreach (ListViewItem item in inView.Concat(later))
+ {
+ var entry = (BlueprintEntry)item.Tag;
+ _thumbsPending.Remove(entry);
+ if (LoadBlueprint(entry.Path) is { Count: > 0 } tiles)
+ {
+ using Bitmap thumb = Blueprints.Thumbnail(tiles, _blueprintIconSize);
+ _blueprintIcons.Images.Add(thumb);
+ entry.ImageIndex = _blueprintIcons.Images.Count - 1;
+ item.ImageIndex = entry.ImageIndex;
+ }
+
+ if (watch.ElapsedMilliseconds > 30)
+ {
+ break;
+ }
+ }
+ }
+
+ ///
+ /// The group bar: one drop-down to choose the group shown (a bulk save can make dozens of
+ /// groups, too many for a button each), which also renames or removes the group shown,
+ /// then New group and Refresh.
+ ///
+ private void RebuildBlueprintGroupBar()
+ {
+ _blueprintGroupBar.Items.Clear();
+
+ int CountOf(string group) => group == null ? _blueprints.Count : _blueprints.Count(p => p.Group == group);
+ static string Label(string group) => group == null ? "All" : group.Length == 0 ? "Ungrouped" : group;
+
+ // A favorite shown is marked by its own button; the drop-down then just says what it is.
+ List favorites = FavoriteGroups();
+ bool favoriteShown = favorites.Contains(_blueprintGroup);
+ var chooser = new ToolStripDropDownButton(favoriteShown ? "Groups" : $"{Label(_blueprintGroup)} ({CountOf(_blueprintGroup)})")
+ {
+ DisplayStyle = ToolStripItemDisplayStyle.Text,
+ ToolTipText = "The group shown",
+ AutoToolTip = false
+ };
+
+ void Add(string group)
+ {
+ var item = new ToolStripMenuItem($"{Label(group)} ({CountOf(group)})") { Checked = _blueprintGroup == group };
+ item.Click += (_, _) =>
{
- _prefabGroup = group;
- RebuildPrefabGroupBar();
- RebuildPrefabList();
+ _blueprintGroup = group;
+ RebuildBlueprintGroupBar();
+ RebuildBlueprintList();
};
- if (!string.IsNullOrEmpty(group))
+ chooser.DropDownItems.Add(item);
+ }
+
+ Add(null);
+ Add(string.Empty);
+ if (_blueprintGroups.Count > 0)
+ {
+ chooser.DropDownItems.Add(new ToolStripSeparator());
+ foreach (string group in _blueprintGroups)
{
- // A named group can be renamed or removed from its own button.
- button.MouseUp += (_, e) =>
- {
- if (e.Button == MouseButtons.Right)
- {
- ShowGroupMenu(group, _prefabGroupBar.PointToClient(Cursor.Position));
- }
- };
+ Add(group);
}
+ }
- _prefabGroupBar.Items.Add(button);
+ if (!string.IsNullOrEmpty(_blueprintGroup))
+ {
+ string shown = _blueprintGroup;
+ bool favorite = favorites.Contains(shown, StringComparer.OrdinalIgnoreCase);
+ chooser.DropDownItems.Add(new ToolStripSeparator());
+ chooser.DropDownItems.Add(favorite ? $"Remove \"{shown}\" from favorites" : $"Add \"{shown}\" to favorites", null,
+ (_, _) => SetFavoriteGroup(shown, !favorite));
+ chooser.DropDownItems.Add($"Rename \"{shown}\"...", null, (_, _) => RenameBlueprintGroup(shown));
+ chooser.DropDownItems.Add($"Remove \"{shown}\" (its blueprints become ungrouped)", null, (_, _) => RemoveBlueprintGroup(shown));
}
- Add("All", null, "Every blueprint");
- Add("Ungrouped", string.Empty, "Blueprints in no group");
- foreach (string group in _prefabGroups)
+ _blueprintGroupBar.Items.Add(chooser);
+
+ // Favorite groups stay on the bar, one click away; right click takes one off.
+ foreach (string group in favorites)
{
- Add(group, group, "Right click to rename or remove this group");
+ string favorite = group;
+ var button = new ToolStripButton("★ " + favorite)
+ {
+ DisplayStyle = ToolStripItemDisplayStyle.Text,
+ Checked = _blueprintGroup == favorite,
+ ToolTipText = $"{favorite}: {CountOf(favorite)} blueprints. Right click to remove it from favorites.",
+ AutoToolTip = false
+ };
+ button.Click += (_, _) =>
+ {
+ _blueprintGroup = favorite;
+ RebuildBlueprintGroupBar();
+ RebuildBlueprintList();
+ };
+ button.MouseUp += (_, e) =>
+ {
+ if (e.Button == MouseButtons.Right)
+ {
+ _blueprintMenu.Items.Clear();
+ _blueprintMenu.Items.Add($"Remove \"{favorite}\" from favorites", null, (_, _) => SetFavoriteGroup(favorite, false));
+ _blueprintMenu.Show(_blueprintGroupBar, _blueprintGroupBar.PointToClient(Cursor.Position));
+ }
+ };
+ _blueprintGroupBar.Items.Add(button);
}
var create = new ToolStripButton("+ Group") { DisplayStyle = ToolStripItemDisplayStyle.Text, ToolTipText = "Make a new group", AutoToolTip = false };
- create.Click += (_, _) => CreatePrefabGroup(null);
- _prefabGroupBar.Items.Add(new ToolStripSeparator());
- _prefabGroupBar.Items.Add(create);
+ create.Click += (_, _) => CreateBlueprintGroup(null);
+ _blueprintGroupBar.Items.Add(new ToolStripSeparator());
+ _blueprintGroupBar.Items.Add(create);
var refresh = new ToolStripButton("Refresh") { DisplayStyle = ToolStripItemDisplayStyle.Text, ToolTipText = "Read the blueprint folder again", AutoToolTip = false };
- refresh.Click += (_, _) => LoadPrefabs();
- _prefabGroupBar.Items.Add(refresh);
+ refresh.Click += (_, _) => LoadBlueprints();
+ _blueprintGroupBar.Items.Add(refresh);
}
/// Shows the blueprints of the chosen group whose name matches the search, or that hold a searched id.
- private void RebuildPrefabList()
+ private void RebuildBlueprintList()
{
TileBrowserQuery query = _query;
- string selected = (_prefabList.SelectedItems.Count > 0 ? _prefabList.SelectedItems[0].Tag as PrefabEntry : null)?.Path;
- _prefabList.BeginUpdate();
+ string selected = (_blueprintList.SelectedItems.Count > 0 ? _blueprintList.SelectedItems[0].Tag as BlueprintEntry : null)?.Path;
+ _blueprintList.BeginUpdate();
try
{
- _prefabList.Items.Clear();
- foreach (PrefabEntry prefab in _prefabs)
+ _blueprintList.Items.Clear();
+ foreach (BlueprintEntry blueprint in _blueprints)
{
- if (_prefabGroup != null && prefab.Group != _prefabGroup)
+ if (_blueprintGroup != null && blueprint.Group != _blueprintGroup)
{
continue;
}
- if (query != null && !(query.IsIdQuery ? prefab.Ids.Any(query.MatchesId) : query.MatchesText(prefab.Group + " " + prefab.Name)))
+ if (query != null && !(query.IsIdQuery ? blueprint.Ids.Any(query.MatchesId) : query.MatchesText(blueprint.Group + " " + blueprint.Name)))
{
continue;
}
- string where = prefab.Group.Length > 0 ? $" (group {prefab.Group})" : string.Empty;
- var item = new ListViewItem(prefab.Name, prefab.ImageIndex)
+ // Thumbnails are all scaled to one size, so the label says how big it really is.
+ string where = blueprint.Group.Length > 0 ? $"\ngroup {blueprint.Group}" : string.Empty;
+ var item = new ListViewItem($"{blueprint.Name} ({Blueprints.FootprintText(blueprint.Footprint, false)})", blueprint.ImageIndex)
{
- Tag = prefab,
- ToolTipText = $"{prefab.Name}: {prefab.TileCount} tiles{where}"
+ Tag = blueprint,
+ ToolTipText = $"{blueprint.Name}\n{Blueprints.FootprintText(blueprint.Footprint, true)}, {blueprint.TileCount:N0} tiles{where}"
};
- _prefabList.Items.Add(item);
- item.Selected = prefab.Path == selected;
+ _blueprintList.Items.Add(item);
+ item.Selected = blueprint.Path == selected;
}
}
finally
{
- _prefabList.EndUpdate();
+ _blueprintList.EndUpdate();
}
- _prefabsVersion = _filterVersion;
+ StartThumbnails();
+ _blueprintsVersion = _filterVersion;
UpdateStatus();
}
- private PrefabEntry SelectedPrefab => _prefabList.SelectedItems.Count > 0 ? _prefabList.SelectedItems[0].Tag as PrefabEntry : null;
+ private BlueprintEntry SelectedBlueprint => _blueprintList.SelectedItems.Count > 0 ? _blueprintList.SelectedItems[0].Tag as BlueprintEntry : null;
- private void PlaceSelectedPrefab()
+ private void PlaceSelectedBlueprint()
{
- if (SelectedPrefab is not { } prefab)
+ if (SelectedBlueprint is not { } blueprint)
{
return;
}
// Read again so the shell gets its own list and sees the file as it is now.
- List tiles = LoadPrefab(prefab.Path);
+ List tiles = LoadBlueprint(blueprint.Path);
if (tiles != null && tiles.Count > 0)
{
- PrefabChosen?.Invoke(tiles);
+ BlueprintChosen?.Invoke(tiles);
}
}
- private void ShowPrefabMenu(Point location)
+ private void ShowBlueprintMenu(Point location)
{
- if (SelectedPrefab is not { } prefab)
+ if (SelectedBlueprint is not { } blueprint)
{
return;
}
- _prefabMenu.Items.Clear();
- _prefabMenu.Items.Add("Place", null, (_, _) => PlaceSelectedPrefab());
+ _blueprintMenu.Items.Clear();
+ _blueprintMenu.Items.Add("Place", null, (_, _) => PlaceSelectedBlueprint());
var move = new ToolStripMenuItem("Move to group");
void Target(string text, string group)
{
- var item = new ToolStripMenuItem(text) { Checked = prefab.Group == group, Enabled = prefab.Group != group };
- item.Click += (_, _) => MovePrefab(prefab, group);
+ var item = new ToolStripMenuItem(text) { Checked = blueprint.Group == group, Enabled = blueprint.Group != group };
+ item.Click += (_, _) => MoveBlueprint(blueprint, group);
move.DropDownItems.Add(item);
}
Target("Ungrouped", string.Empty);
- foreach (string group in _prefabGroups)
+ foreach (string group in _blueprintGroups)
{
Target(group, group);
}
move.DropDownItems.Add(new ToolStripSeparator());
- move.DropDownItems.Add("New group...", null, (_, _) => CreatePrefabGroup(prefab));
- _prefabMenu.Items.Add(move);
- _prefabMenu.Items.Add("Rename...", null, (_, _) => RenameSelectedPrefab());
- _prefabMenu.Items.Add("Delete", null, (_, _) => DeleteSelectedPrefab());
- _prefabMenu.Show(_prefabList, location);
+ move.DropDownItems.Add("New group...", null, (_, _) => CreateBlueprintGroup(blueprint));
+ _blueprintMenu.Items.Add(move);
+ _blueprintMenu.Items.Add("Rename...", null, (_, _) => RenameSelectedBlueprint());
+ _blueprintMenu.Items.Add("Delete", null, (_, _) => DeleteSelectedBlueprint());
+ _blueprintMenu.Show(_blueprintList, location);
}
/// Runs a file operation on the blueprint folder, shows why when it fails, and re-reads the folder.
- private void PrefabFileAction(string what, Action action)
+ private void BlueprintFileAction(string what, Action action)
{
try
{
@@ -1974,29 +2162,29 @@ private void PrefabFileAction(string what, Action action)
MessageBox.Show(this, $"Could not {what}: {ex.Message}", _messageTitle, MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
- LoadPrefabs();
+ LoadBlueprints();
}
- private void MovePrefab(PrefabEntry prefab, string group)
+ private void MoveBlueprint(BlueprintEntry blueprint, string group)
{
- PrefabFileAction("move the blueprint", () =>
+ BlueprintFileAction("move the blueprint", () =>
{
string folder = GroupFolder(group);
Directory.CreateDirectory(folder);
- File.Move(prefab.Path, Path.Combine(folder, Path.GetFileName(prefab.Path)));
+ File.Move(blueprint.Path, Path.Combine(folder, Path.GetFileName(blueprint.Path)));
});
}
/// Asks for a group name and makes it; with a blueprint, moves that one into it.
- private void CreatePrefabGroup(PrefabEntry moveIn)
+ private void CreateBlueprintGroup(BlueprintEntry moveIn)
{
- if (Prompt.Text(this, "New blueprint group", "Group name:") is not { } typed || Prefabs.SafeName(typed).Length == 0)
+ if (Prompt.Text(this, "New blueprint group", "Group name:") is not { } typed || Blueprints.SafeName(typed).Length == 0)
{
return;
}
- string group = Prefabs.SafeName(typed);
- PrefabFileAction("make the group", () =>
+ string group = Blueprints.SafeName(typed);
+ BlueprintFileAction("make the group", () =>
{
Directory.CreateDirectory(GroupFolder(group));
if (moveIn != null)
@@ -2005,67 +2193,150 @@ private void CreatePrefabGroup(PrefabEntry moveIn)
}
});
- _prefabGroup = _prefabGroups.Contains(group) ? group : _prefabGroup;
- RebuildPrefabGroupBar();
- RebuildPrefabList();
+ _blueprintGroup = _blueprintGroups.Contains(group) ? group : _blueprintGroup;
+ RebuildBlueprintGroupBar();
+ RebuildBlueprintList();
}
- private void ShowGroupMenu(string group, Point location)
+ ///
+ /// The favorite groups that still exist, by their folder's own spelling, in the order they
+ /// were added.
+ ///
+ private List FavoriteGroups()
{
- _prefabMenu.Items.Clear();
- _prefabMenu.Items.Add("Rename group...", null, (_, _) =>
+ var favorites = new List();
+ foreach (string saved in EditorSettings.Current.FavoriteBlueprintGroups.Split('|', StringSplitOptions.RemoveEmptyEntries))
{
- if (Prompt.Text(this, "Rename blueprint group", "Group name:", group) is { } typed &&
- Prefabs.SafeName(typed) is { Length: > 0 } name && name != group)
+ string group = _blueprintGroups.Find(g => string.Equals(g, saved, StringComparison.OrdinalIgnoreCase));
+ if (group != null && !favorites.Contains(group, StringComparer.OrdinalIgnoreCase))
{
- if (_prefabGroup == group)
- {
- _prefabGroup = name;
- }
-
- PrefabFileAction("rename the group", () => Directory.Move(GroupFolder(group), GroupFolder(name)));
+ favorites.Add(group);
}
+ }
+
+ return favorites;
+ }
+
+ private static void SaveFavoriteGroups(IEnumerable groups)
+ {
+ string text = string.Join('|', groups.Distinct(StringComparer.OrdinalIgnoreCase));
+ if (text != EditorSettings.Current.FavoriteBlueprintGroups)
+ {
+ EditorSettings settings = EditorSettings.Current.Clone();
+ settings.FavoriteBlueprintGroups = text;
+ settings.Apply();
+ }
+ }
+
+ /// Adds a group to the favorites or takes it off, and redraws the bar.
+ private void SetFavoriteGroup(string group, bool favorite)
+ {
+ List favorites = FavoriteGroups();
+ favorites.RemoveAll(g => string.Equals(g, group, StringComparison.OrdinalIgnoreCase));
+ if (favorite)
+ {
+ favorites.Add(group);
+ }
+
+ SaveFavoriteGroups(favorites);
+ RebuildBlueprintGroupBar();
+ }
+
+ ///
+ /// Forgets favorites whose folder is gone (deleted or renamed outside the editor), after the
+ /// blueprint folder was read.
+ ///
+ private void DropMissingFavoriteGroups()
+ {
+ SaveFavoriteGroups(FavoriteGroups());
+ }
+
+ private void RenameBlueprintGroup(string group)
+ {
+ if (Prompt.Text(this, "Rename blueprint group", "Group name:", group) is not { } typed ||
+ Blueprints.SafeName(typed) is not { Length: > 0 } name || name == group)
+ {
+ return;
+ }
+
+ if (_blueprintGroup == group)
+ {
+ _blueprintGroup = name;
+ }
+
+ // A favorite keeps its place under the new name, once the folder has moved.
+ List favorites = FavoriteGroups().ConvertAll(g => string.Equals(g, group, StringComparison.OrdinalIgnoreCase) ? name : g);
+ BlueprintFileAction("rename the group", () =>
+ {
+ Directory.Move(GroupFolder(group), GroupFolder(name));
+ SaveFavoriteGroups(favorites);
});
- _prefabMenu.Items.Add("Remove group (its blueprints become ungrouped)", null, (_, _) =>
+ }
+
+ /// Moves a group's blueprints to no group and removes the group's folder once it is empty.
+ private void RemoveBlueprintGroup(string group)
+ {
+ if (_blueprintGroup == group)
{
- PrefabFileAction("remove the group", () =>
+ _blueprintGroup = string.Empty;
+ }
+
+ BlueprintFileAction("remove the group", () =>
+ {
+ foreach (string path in Directory.EnumerateFiles(GroupFolder(group), "*.txt").ToList())
{
- foreach (string path in Directory.EnumerateFiles(GroupFolder(group), "*.txt").ToList())
- {
- File.Move(path, Path.Combine(Prefabs.Folder, Path.GetFileName(path)));
- }
+ // A name already taken in no group gets " (2)"..., so nothing is overwritten or left behind.
+ (string target, _) = Blueprints.UniqueName(Blueprints.Folder, Path.GetFileNameWithoutExtension(path));
+ File.Move(path, target);
+ }
- // Only an empty folder goes: anything else in it is not ours to delete.
- if (!Directory.EnumerateFileSystemEntries(GroupFolder(group)).Any())
- {
- Directory.Delete(GroupFolder(group));
- }
- });
+ // Only an empty folder goes: anything else in it is not ours to delete.
+ if (!Directory.EnumerateFileSystemEntries(GroupFolder(group)).Any())
+ {
+ Directory.Delete(GroupFolder(group));
+ }
});
- _prefabMenu.Show(_prefabGroupBar, location);
}
- private void RenameSelectedPrefab()
+ ///
+ /// Renames a blueprint: the name line in the file and the file itself, which gets " (2)"...
+ /// when another blueprint in the group has that file name.
+ ///
+ private void RenameSelectedBlueprint()
{
- if (SelectedPrefab is not { } prefab ||
- Prompt.Text(this, "Rename blueprint", "Blueprint name:", prefab.Name) is not { } typed ||
- Prefabs.SafeName(typed) is not { Length: > 0 } name || name == prefab.Name)
+ if (SelectedBlueprint is not { } blueprint ||
+ Prompt.Text(this, "Rename blueprint", "Blueprint name:", blueprint.Name) is not { } typed ||
+ typed.Trim() is not { Length: > 0 } wanted || wanted == blueprint.Name || Blueprints.SafeName(wanted).Length == 0)
{
return;
}
- PrefabFileAction("rename the blueprint", () => File.Move(prefab.Path, Path.Combine(Path.GetDirectoryName(prefab.Path), name + ".txt")));
+ BlueprintFileAction("rename the blueprint", () =>
+ {
+ (List tiles, _) = Blueprints.Read(blueprint.Path);
+ if (tiles == null)
+ {
+ throw new IOException("the file could not be read.");
+ }
+
+ (string path, string name) = Blueprints.UniqueName(Path.GetDirectoryName(blueprint.Path), wanted, own: blueprint.Path);
+ Blueprints.Write(path, tiles, name);
+ if (!string.Equals(Path.GetFullPath(path), Path.GetFullPath(blueprint.Path), StringComparison.OrdinalIgnoreCase))
+ {
+ File.Delete(blueprint.Path);
+ }
+ });
}
- private void DeleteSelectedPrefab()
+ private void DeleteSelectedBlueprint()
{
- if (SelectedPrefab is not { } prefab ||
- MessageBox.Show(this, $"Delete the blueprint \"{prefab.Name}\"?", _messageTitle, MessageBoxButtons.OKCancel, MessageBoxIcon.Question) != DialogResult.OK)
+ if (SelectedBlueprint is not { } blueprint ||
+ MessageBox.Show(this, $"Delete the blueprint \"{blueprint.Name}\"?", _messageTitle, MessageBoxButtons.OKCancel, MessageBoxIcon.Question) != DialogResult.OK)
{
return;
}
- PrefabFileAction("delete the blueprint", () => File.Delete(prefab.Path));
+ BlueprintFileAction("delete the blueprint", () => File.Delete(blueprint.Path));
}
}
}
diff --git a/UoFiddler.Plugin.MultiEditor/UoFiddler.Plugin.MultiEditor.csproj b/UoFiddler.Plugin.MultiEditor/UoFiddler.Plugin.MultiEditor.csproj
index a4a02bae..0484dc45 100644
--- a/UoFiddler.Plugin.MultiEditor/UoFiddler.Plugin.MultiEditor.csproj
+++ b/UoFiddler.Plugin.MultiEditor/UoFiddler.Plugin.MultiEditor.csproj
@@ -41,6 +41,12 @@
+
+ Form
+
+
+ BlueprintSaveDialog.cs
+
UserControl
@@ -127,6 +133,9 @@
+
+ BlueprintSaveDialog.cs
+
DesignLibraryPanel.cs
From 6749d086c299ba862f451a9e351c3f84d1a40dfe Mon Sep 17 00:00:00 2001
From: AsY!um- <377468+AsYlum-@users.noreply.github.com>
Date: Sat, 14 Nov 2026 16:43:38 +0100
Subject: [PATCH 2/5] Import and export CentrED# blueprints
Reads CentrED# CSV blueprints and TilesEntry XML (one multi per entry); both are detected on import.
---
Ultima/MultiComponentList.cs | 130 ++++++++++++++++++
Ultima/Multis.cs | 4 +-
UoFiddler.Plugin.MultiEditor/UI/DocumentIO.cs | 10 +-
.../UI/MultiFileImport.cs | 55 +++++++-
4 files changed, 194 insertions(+), 5 deletions(-)
diff --git a/Ultima/MultiComponentList.cs b/Ultima/MultiComponentList.cs
index 1d072142..a549cc7e 100644
--- a/Ultima/MultiComponentList.cs
+++ b/Ultima/MultiComponentList.cs
@@ -12,7 +12,9 @@
using System;
using System.Collections.Generic;
using System.Drawing;
+using System.Globalization;
using System.IO;
+using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using System.Xml;
@@ -649,6 +651,13 @@ public MultiComponentList(string fileName, Multis.ImportType type)
case Multis.ImportType.MULTIFORGE:
parsed = ReadMultiforge(fileName);
break;
+ case Multis.ImportType.CENTREDCSV:
+ parsed = ReadCentredCsv(fileName);
+ break;
+ case Multis.ImportType.TILESENTRY:
+ // Every entry in one; ReadTilesEntries keeps them apart.
+ parsed = ReadTilesEntries(fileName).SelectMany(e => e.Tiles).ToList();
+ break;
}
if (parsed != null)
@@ -774,6 +783,72 @@ private static List ReadXml(string fileName)
return list;
}
+ ///
+ /// A CentrED# blueprint: an "id,x,y,z,hue,flags" header, then one tile per line with the id in
+ /// decimal or 0x hex and X/Y around the blueprint's centre. CentrED# writes the flags as 0 and
+ /// never reads them, so every tile is visible; multis have no hue, so it is dropped.
+ ///
+ private static List ReadCentredCsv(string fileName)
+ {
+ var list = new List();
+ foreach (string raw in File.ReadLines(fileName))
+ {
+ string line = raw.Trim();
+ if (line.Length == 0 || line.StartsWith("id,", StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ string[] parts = line.Split(',');
+ if (parts.Length < 4)
+ {
+ throw new InvalidDataException($"Not a CentrED# blueprint line: {line}");
+ }
+
+ list.Add(Entry(ParseNumber(parts[0]), ParseNumber(parts[1]), ParseNumber(parts[2]), ParseNumber(parts[3])));
+ }
+
+ return list;
+ }
+
+ ///
+ /// The entries of a CentrED# TilesEntry file: <TilesEntry><Entry Name><Item ID X Y Z Hue />,
+ /// each a blueprint of its own. Missing attributes read as 0, like CentrED# reads them; an
+ /// entry without a name is "Unknown" and its index. Hues are dropped.
+ ///
+ public static List<(string Name, List Tiles)> ReadTilesEntries(string fileName)
+ {
+ var dom = new XmlDocument();
+ dom.Load(fileName);
+ if (dom.DocumentElement?.Name != "TilesEntry")
+ {
+ throw new InvalidDataException("Not a TilesEntry file");
+ }
+
+ static int Attribute(XmlElement element, string name)
+ {
+ string value = element.GetAttribute(name);
+ return value.Length > 0 ? ParseNumber(value) : 0;
+ }
+
+ var result = new List<(string, List)>();
+ int index = 0;
+ foreach (XmlElement entry in dom.DocumentElement.GetElementsByTagName("Entry"))
+ {
+ var tiles = new List();
+ foreach (XmlElement item in entry.GetElementsByTagName("Item"))
+ {
+ tiles.Add(Entry(Attribute(item, "ID"), Attribute(item, "X"), Attribute(item, "Y"), Attribute(item, "Z")));
+ }
+
+ string name = entry.GetAttribute("Name");
+ result.Add((name.Length > 0 ? name : $"Unknown{index}", tiles));
+ ++index;
+ }
+
+ return result;
+ }
+
///
/// A UO House Editor design: a header line ("UOHE File ver 1.00",width,height,base), then
/// one line per item: floor,x,y,altitude,item id (decimal). Floor 0 is the foundation with
@@ -1377,6 +1452,61 @@ public void ExportToCsvFile(string fileName)
}
}
+ ///
+ /// A CentrED# blueprint, as its CsvWriter writes one: decimal ids, X and Y around the
+ /// blueprint's centre, Z from its lowest tile, hue and flags 0. CentrED# has no invisible
+ /// tiles, so every tile is written.
+ ///
+ public void ExportToCentredCsvFile(string fileName)
+ {
+ (int dx, int dy, int dz) = CentredOrigin();
+ using var writer = new StreamWriter(fileName);
+ writer.WriteLine("id,x,y,z,hue,flags");
+ foreach (MultiTileEntry e in SortedTiles)
+ {
+ writer.WriteLine(string.Create(CultureInfo.InvariantCulture, $"{e.ItemId},{e.OffsetX - dx},{e.OffsetY - dy},{e.OffsetZ - dz},0,0"));
+ }
+ }
+
+ /// A CentrED# TilesEntry file holding this multi as its one entry, named .
+ public void ExportToTilesEntryFile(string fileName, string name)
+ {
+ (int dx, int dy, int dz) = CentredOrigin();
+ using XmlWriter xmlWriter = XmlWriter.Create(fileName, new XmlWriterSettings { Indent = true });
+ xmlWriter.WriteStartElement("TilesEntry");
+ xmlWriter.WriteStartElement("Entry");
+ xmlWriter.WriteAttributeString("Name", name);
+ foreach (MultiTileEntry e in SortedTiles)
+ {
+ xmlWriter.WriteStartElement("Item");
+ xmlWriter.WriteAttributeString("ID", $"0x{e.ItemId:X4}");
+ xmlWriter.WriteAttributeString("X", (e.OffsetX - dx).ToString(CultureInfo.InvariantCulture));
+ xmlWriter.WriteAttributeString("Y", (e.OffsetY - dy).ToString(CultureInfo.InvariantCulture));
+ xmlWriter.WriteAttributeString("Z", (e.OffsetZ - dz).ToString(CultureInfo.InvariantCulture));
+ xmlWriter.WriteAttributeString("Hue", "0");
+ xmlWriter.WriteEndElement(); // Item
+ }
+
+ xmlWriter.WriteEndElement(); // Entry
+ xmlWriter.WriteEndElement(); // TilesEntry
+ }
+
+ ///
+ /// What CentrED# subtracts to make a blueprint: the centre of its X/Y bounds (halfway,
+ /// rounded down, as its own save does) and its lowest Z.
+ ///
+ private (int X, int Y, int Z) CentredOrigin()
+ {
+ if (SortedTiles == null || SortedTiles.Length == 0)
+ {
+ return (0, 0, 0);
+ }
+
+ int minX = SortedTiles.Min(e => e.OffsetX), maxX = SortedTiles.Max(e => e.OffsetX);
+ int minY = SortedTiles.Min(e => e.OffsetY), maxY = SortedTiles.Max(e => e.OffsetY);
+ return (minX + ((maxX - minX + 1) / 2), minY + ((maxY - minY + 1) / 2), SortedTiles.Min(e => e.OffsetZ));
+ }
+
public void ExportToXmlFile(string fileName, string entryId)
{
using (var xmlWriter = XmlWriter.Create(fileName, new XmlWriterSettings { Indent = true }))
diff --git a/Ultima/Multis.cs b/Ultima/Multis.cs
index 517bbcbb..209e2377 100644
--- a/Ultima/Multis.cs
+++ b/Ultima/Multis.cs
@@ -61,7 +61,9 @@ public enum ImportType
XML,
UOH, // UO House Editor (UOHE) design
WORLDFORGE, // WorldForge tile export
- MULTIFORGE // POL multiforge package text: id x y z hue [multi flag]
+ MULTIFORGE, // POL multiforge package text: id x y z hue [multi flag]
+ CENTREDCSV, // CentrED# blueprint: "id,x,y,z,hue,flags" header, then one tile per line
+ TILESENTRY // CentrED# TilesEntry xml: named Entry elements holding Item ID X Y Z Hue
}
///
diff --git a/UoFiddler.Plugin.MultiEditor/UI/DocumentIO.cs b/UoFiddler.Plugin.MultiEditor/UI/DocumentIO.cs
index e0d16f7c..9ce48f3d 100644
--- a/UoFiddler.Plugin.MultiEditor/UI/DocumentIO.cs
+++ b/UoFiddler.Plugin.MultiEditor/UI/DocumentIO.cs
@@ -46,7 +46,9 @@ private static readonly (Multis.ImportType Type, string Extension, string Descri
(Multis.ImportType.XML, "xml", "XML"),
(Multis.ImportType.UOH, "uoh", "UO House Editor design"),
(Multis.ImportType.WORLDFORGE, "xml", "WorldForge export"),
- (Multis.ImportType.MULTIFORGE, "txt", "Multiforge text (POL)")
+ (Multis.ImportType.MULTIFORGE, "txt", "Multiforge text (POL)"),
+ (Multis.ImportType.CENTREDCSV, "csv", "CentrED# blueprint"),
+ (Multis.ImportType.TILESENTRY, "xml", "CentrED# TilesEntry")
};
///
@@ -355,6 +357,12 @@ public static bool ExportToFile(IWin32Window owner, MultiEditorComponentList lis
case Multis.ImportType.MULTIFORGE:
sdkList.ExportToMultiforgeFile(fileName);
break;
+ case Multis.ImportType.CENTREDCSV:
+ sdkList.ExportToCentredCsvFile(fileName);
+ break;
+ case Multis.ImportType.TILESENTRY:
+ sdkList.ExportToTilesEntryFile(fileName, designName);
+ break;
}
}
catch (Exception ex)
diff --git a/UoFiddler.Plugin.MultiEditor/UI/MultiFileImport.cs b/UoFiddler.Plugin.MultiEditor/UI/MultiFileImport.cs
index 7821df3a..18f7497c 100644
--- a/UoFiddler.Plugin.MultiEditor/UI/MultiFileImport.cs
+++ b/UoFiddler.Plugin.MultiEditor/UI/MultiFileImport.cs
@@ -81,7 +81,9 @@ public static readonly (Multis.ImportType Type, string Description, string Patte
(Multis.ImportType.XML, "XML file (UOFiddler export)", "*.xml"),
(Multis.ImportType.UOH, "UO House Editor design", "*.uoh"),
(Multis.ImportType.WORLDFORGE, "WorldForge export", "*.xml"),
- (Multis.ImportType.MULTIFORGE, "Multiforge text file (POL)", "*.txt")
+ (Multis.ImportType.MULTIFORGE, "Multiforge text file (POL)", "*.txt"),
+ (Multis.ImportType.CENTREDCSV, "CentrED# blueprint", "*.csv"),
+ (Multis.ImportType.TILESENTRY, "CentrED# TilesEntry", "*.xml")
};
/// Short format names for lists.
@@ -99,6 +101,8 @@ public static readonly (Multis.ImportType Type, string Description, string Patte
Multis.ImportType.UOH => "UO House Editor",
Multis.ImportType.WORLDFORGE => "WorldForge",
Multis.ImportType.MULTIFORGE => "Multiforge",
+ Multis.ImportType.CENTREDCSV => "CentrED# CSV",
+ Multis.ImportType.TILESENTRY => "CentrED# TilesEntry",
_ => type.ToString()
};
@@ -139,7 +143,7 @@ public static IEnumerable CandidateFiles(string folder, bool recursive)
".xml" => DetectXml(path),
".uoa" => Multis.ImportType.UOAB,
".wsc" => Multis.ImportType.WSC,
- ".csv" => Multis.ImportType.CSV,
+ ".csv" => DetectCsv(path),
".uox3" => Multis.ImportType.UOX3,
".uoh" => Multis.ImportType.UOH,
".idx" => IsDesignSet(path) ? Multis.ImportType.UOADESIGN : null,
@@ -193,6 +197,23 @@ public static List Load(string path, Multis.ImportType type)
result.Add(new ImportedMulti(name, type, path, design.ItemCount, () => LoadDesign(design)));
}
+ break;
+ }
+ case Multis.ImportType.TILESENTRY:
+ {
+ // Each entry is a blueprint of its own, like the designs of a UOA batch.
+ List<(string Name, List Tiles)> entries = MultiComponentList.ReadTilesEntries(path);
+ foreach ((string name, List tiles) in entries)
+ {
+ int count = tiles.Count;
+ if (count > 0)
+ {
+ // The constructor empties the list it is given.
+ var multi = new MultiComponentList(tiles);
+ result.Add(new ImportedMulti(entries.Count == 1 ? title : $"{title} - {name}", type, path, count, multi));
+ }
+ }
+
break;
}
default:
@@ -349,7 +370,29 @@ private static bool TryParseNumber(string text, out int value) =>
private static int ParseNumber(string text) => TryParseNumber(text, out int value) ? value : -1;
- /// A UOFiddler export has Item elements with an ID; a WorldForge export has tile elements with an id.
+ ///
+ /// A CentrED# blueprint starts with an "id,x,y,z" header; anything else is read as Punt's
+ /// multi tool CSV ("TileID,OffsetX..." header, hex ids).
+ ///
+ private static Multis.ImportType DetectCsv(string path)
+ {
+ using var reader = new StreamReader(path);
+ while (reader.ReadLine() is { } line)
+ {
+ line = line.Trim();
+ if (line.Length > 0)
+ {
+ return line.StartsWith("id,x,y,z", StringComparison.OrdinalIgnoreCase) ? Multis.ImportType.CENTREDCSV : Multis.ImportType.CSV;
+ }
+ }
+
+ return Multis.ImportType.CSV;
+ }
+
+ ///
+ /// A UOFiddler export has Item elements with an ID; a WorldForge export has tile elements with an id;
+ /// a CentrED# TilesEntry file has a TilesEntry root.
+ ///
private static Multis.ImportType? DetectXml(string path)
{
var settings = new XmlReaderSettings { DtdProcessing = DtdProcessing.Ignore, XmlResolver = null };
@@ -363,6 +406,12 @@ private static bool TryParseNumber(string text, out int value) =>
continue;
}
+ // CentrED# names the root; its Item elements look like a UOFiddler export's.
+ if (reader.LocalName == "TilesEntry")
+ {
+ return Multis.ImportType.TILESENTRY;
+ }
+
if (reader.LocalName == "Item" && reader.GetAttribute("ID") != null)
{
return Multis.ImportType.XML;
From 663b872f22b8a7d29377222f4179e02e2d1177cd Mon Sep 17 00:00:00 2001
From: AsY!um- <377468+AsYlum-@users.noreply.github.com>
Date: Sat, 14 Nov 2026 16:26:53 +0100
Subject: [PATCH 3/5] List selected tiles in the inspector and trim tile
tooltips
---
.../Editing/EditorSession.cs | 40 +++
.../Rendering/SceneRenderer.cs | 18 +-
.../UI/InspectorPanel.Designer.cs | 97 +++++++
.../UI/InspectorPanel.cs | 248 +++++++++++++++++-
.../UI/TileBrowserThumbnails.cs | 51 ++--
5 files changed, 415 insertions(+), 39 deletions(-)
diff --git a/UoFiddler.Plugin.MultiEditor/Editing/EditorSession.cs b/UoFiddler.Plugin.MultiEditor/Editing/EditorSession.cs
index af7dc356..e14adb9d 100644
--- a/UoFiddler.Plugin.MultiEditor/Editing/EditorSession.cs
+++ b/UoFiddler.Plugin.MultiEditor/Editing/EditorSession.cs
@@ -455,6 +455,46 @@ public void ClearSelection()
Invalidated?.Invoke();
}
+ /// Takes tiles out of the selection, with one change notice.
+ public void Deselect(IEnumerable tiles)
+ {
+ bool changed = false;
+ foreach (MultiTile tile in tiles)
+ {
+ changed |= _selection.Remove(tile);
+ }
+
+ if (changed)
+ {
+ SelectionChanged?.Invoke();
+ Invalidated?.Invoke();
+ }
+ }
+
+ private HashSet _listHighlight = new HashSet();
+
+ ///
+ /// Tiles pointed at outside the canvas (a row of the selection list), drawn like the hovered
+ /// tile. Empty for none.
+ ///
+ public IReadOnlyCollection ListHighlight
+ {
+ get => _listHighlight;
+ set
+ {
+ var next = value == null ? new HashSet() : new HashSet(value);
+ if (next.SetEquals(_listHighlight))
+ {
+ return;
+ }
+
+ _listHighlight = next;
+ Invalidated?.Invoke();
+ }
+ }
+
+ public bool IsListHighlighted(MultiTile tile) => _listHighlight.Count > 0 && _listHighlight.Contains(tile);
+
/// True when a tile can be hovered, selected and edited with the current view.
public bool IsEditable(MultiTile tile)
{
diff --git a/UoFiddler.Plugin.MultiEditor/Rendering/SceneRenderer.cs b/UoFiddler.Plugin.MultiEditor/Rendering/SceneRenderer.cs
index 401ed2d4..7b005911 100644
--- a/UoFiddler.Plugin.MultiEditor/Rendering/SceneRenderer.cs
+++ b/UoFiddler.Plugin.MultiEditor/Rendering/SceneRenderer.cs
@@ -202,7 +202,7 @@ private void AddTile(EditorSession session, MultiEditorComponentList list, Multi
}
SpriteTint tint;
- if (tile == hover)
+ if (tile == hover || session.IsListHighlighted(tile))
{
tint = _hover;
}
@@ -503,10 +503,20 @@ private void AddPlan(EditorSession session)
if (session.HoverTile is { } hover)
{
- Point o = PlanView.CellOrigin(hover.X, hover.Y);
- _lines.Rectangle(new RectangleF(o.X + 1, o.Y + 1, cell - 2, cell - 2), _marqueeLine);
- _lines.Rectangle(new RectangleF(o.X + 2, o.Y + 2, cell - 4, cell - 4), _marqueeLine);
+ OutlinePlanCell(hover, cell);
}
+
+ foreach (MultiTile tile in session.ListHighlight)
+ {
+ OutlinePlanCell(tile, cell);
+ }
+ }
+
+ private void OutlinePlanCell(MultiTile tile, int cell)
+ {
+ Point o = PlanView.CellOrigin(tile.X, tile.Y);
+ _lines.Rectangle(new RectangleF(o.X + 1, o.Y + 1, cell - 2, cell - 2), _marqueeLine);
+ _lines.Rectangle(new RectangleF(o.X + 2, o.Y + 2, cell - 4, cell - 4), _marqueeLine);
}
private static MultiTile TopFill(EditorSession session, System.Collections.Generic.List tiles, int maxHeight, bool skipCovers)
diff --git a/UoFiddler.Plugin.MultiEditor/UI/InspectorPanel.Designer.cs b/UoFiddler.Plugin.MultiEditor/UI/InspectorPanel.Designer.cs
index 48b6957f..df3307c4 100644
--- a/UoFiddler.Plugin.MultiEditor/UI/InspectorPanel.Designer.cs
+++ b/UoFiddler.Plugin.MultiEditor/UI/InspectorPanel.Designer.cs
@@ -76,6 +76,14 @@ private void InitializeComponent()
groupSetZButton = new System.Windows.Forms.Button();
groupDeleteButton = new System.Windows.Forms.Button();
groupHideButton = new System.Windows.Forms.Button();
+ selectionUnselectButton = new System.Windows.Forms.Button();
+ selectionKeepButton = new System.Windows.Forms.Button();
+ _groupById = new System.Windows.Forms.CheckBox();
+ _selectionList = new System.Windows.Forms.ListView();
+ selectionColumnTile = new System.Windows.Forms.ColumnHeader();
+ selectionColumnZ = new System.Windows.Forms.ColumnHeader();
+ selectionColumnWhere = new System.Windows.Forms.ColumnHeader();
+ selectionColumnName = new System.Windows.Forms.ColumnHeader();
_hint = new System.Windows.Forms.Label();
floorsBox.SuspendLayout();
floorsPage.SuspendLayout();
@@ -471,12 +479,17 @@ private void InitializeComponent()
_group.Controls.Add(groupSetZButton);
_group.Controls.Add(groupDeleteButton);
_group.Controls.Add(groupHideButton);
+ _group.Controls.Add(selectionUnselectButton);
+ _group.Controls.Add(selectionKeepButton);
+ _group.Controls.Add(_groupById);
+ _group.Controls.Add(_selectionList);
_group.Dock = System.Windows.Forms.DockStyle.Fill;
_group.Location = new System.Drawing.Point(0, 0);
_group.Name = "_group";
_group.Size = new System.Drawing.Size(144, 0);
_group.TabIndex = 1;
_group.Visible = false;
+ _group.Resize += Group_Resize;
//
// _groupLabel
//
@@ -581,6 +594,82 @@ private void InitializeComponent()
groupHideButton.UseVisualStyleBackColor = true;
groupHideButton.Click += GroupHideButton_Click;
//
+ // selectionUnselectButton
+ //
+ selectionUnselectButton.Location = new System.Drawing.Point(124, 146);
+ selectionUnselectButton.Margin = new System.Windows.Forms.Padding(1);
+ selectionUnselectButton.Name = "selectionUnselectButton";
+ selectionUnselectButton.Size = new System.Drawing.Size(70, 26);
+ selectionUnselectButton.TabIndex = 10;
+ selectionUnselectButton.Text = "Unselect";
+ selectionUnselectButton.UseVisualStyleBackColor = true;
+ selectionUnselectButton.Click += SelectionUnselectButton_Click;
+ //
+ // selectionKeepButton
+ //
+ selectionKeepButton.Location = new System.Drawing.Point(198, 146);
+ selectionKeepButton.Margin = new System.Windows.Forms.Padding(1);
+ selectionKeepButton.Name = "selectionKeepButton";
+ selectionKeepButton.Size = new System.Drawing.Size(76, 26);
+ selectionKeepButton.TabIndex = 11;
+ selectionKeepButton.Text = "Keep only";
+ selectionKeepButton.UseVisualStyleBackColor = true;
+ selectionKeepButton.Click += SelectionKeepButton_Click;
+ //
+ // _groupById
+ //
+ _groupById.AutoSize = true;
+ _groupById.Location = new System.Drawing.Point(6, 180);
+ _groupById.Name = "_groupById";
+ _groupById.Size = new System.Drawing.Size(110, 19);
+ _groupById.TabIndex = 12;
+ _groupById.Text = "Group by tile id";
+ _groupById.UseVisualStyleBackColor = true;
+ _groupById.CheckedChanged += GroupById_CheckedChanged;
+ //
+ // _selectionList
+ //
+ _selectionList.Columns.AddRange(new System.Windows.Forms.ColumnHeader[] { selectionColumnTile, selectionColumnZ, selectionColumnWhere, selectionColumnName });
+ _selectionList.FullRowSelect = true;
+ _selectionList.HeaderStyle = System.Windows.Forms.ColumnHeaderStyle.Nonclickable;
+ _selectionList.HideSelection = false;
+ _selectionList.Location = new System.Drawing.Point(4, 204);
+ _selectionList.Name = "_selectionList";
+ _selectionList.Size = new System.Drawing.Size(280, 100);
+ _selectionList.SmallImageList = _icons;
+ _selectionList.TabIndex = 13;
+ _selectionList.UseCompatibleStateImageBehavior = false;
+ _selectionList.View = System.Windows.Forms.View.Details;
+ _selectionList.VirtualMode = true;
+ _selectionList.RetrieveVirtualItem += SelectionList_RetrieveVirtualItem;
+ _selectionList.SelectedIndexChanged += SelectionList_SelectedIndexChanged;
+ _selectionList.VirtualItemsSelectionRangeChanged += SelectionList_VirtualItemsSelectionRangeChanged;
+ _selectionList.DoubleClick += SelectionList_DoubleClick;
+ _selectionList.KeyDown += SelectionList_KeyDown;
+ _selectionList.MouseMove += SelectionList_MouseMove;
+ _selectionList.MouseLeave += SelectionList_MouseLeave;
+ _selectionList.MouseUp += SelectionList_MouseUp;
+ //
+ // selectionColumnTile
+ //
+ selectionColumnTile.Text = "Tile";
+ selectionColumnTile.Width = 96;
+ //
+ // selectionColumnZ
+ //
+ selectionColumnZ.Text = "Z";
+ selectionColumnZ.Width = 44;
+ //
+ // selectionColumnWhere
+ //
+ selectionColumnWhere.Text = "X, Y";
+ selectionColumnWhere.Width = 56;
+ //
+ // selectionColumnName
+ //
+ selectionColumnName.Text = "Name";
+ selectionColumnName.Width = 120;
+ //
// _hint
//
_hint.Dock = System.Windows.Forms.DockStyle.Fill;
@@ -666,6 +755,14 @@ private void InitializeComponent()
private System.Windows.Forms.Button groupSetZButton;
private System.Windows.Forms.Button groupDeleteButton;
private System.Windows.Forms.Button groupHideButton;
+ private System.Windows.Forms.Button selectionUnselectButton;
+ private System.Windows.Forms.Button selectionKeepButton;
+ private System.Windows.Forms.CheckBox _groupById;
+ private System.Windows.Forms.ListView _selectionList;
+ private System.Windows.Forms.ColumnHeader selectionColumnTile;
+ private System.Windows.Forms.ColumnHeader selectionColumnZ;
+ private System.Windows.Forms.ColumnHeader selectionColumnWhere;
+ private System.Windows.Forms.ColumnHeader selectionColumnName;
private System.Windows.Forms.Label _hint;
}
}
diff --git a/UoFiddler.Plugin.MultiEditor/UI/InspectorPanel.cs b/UoFiddler.Plugin.MultiEditor/UI/InspectorPanel.cs
index 14a2a4a5..aff3c252 100644
--- a/UoFiddler.Plugin.MultiEditor/UI/InspectorPanel.cs
+++ b/UoFiddler.Plugin.MultiEditor/UI/InspectorPanel.cs
@@ -25,13 +25,16 @@ namespace UoFiddler.Plugin.MultiEditor.UI
internal sealed partial class InspectorPanel : UserControl
{
private const int _iconSize = 32;
- private const TileFlag _shownFlags = TileFlag.Surface | TileFlag.Impassable | TileFlag.Wall | TileFlag.Bridge |
- TileFlag.Roof | TileFlag.Door | TileFlag.Window | TileFlag.Foliage |
- TileFlag.Wet | TileFlag.Background | TileFlag.Translucent | TileFlag.Damaging;
private readonly Dictionary _iconIndex = new Dictionary();
private readonly List<(string Name, int Floor, int Ceiling)> _levels = new List<(string Name, int Floor, int Ceiling)>();
+ /// Rows of the selection list: one tile each, or every selected tile of one id when grouped.
+ private readonly List _selectionRows = new List();
+ private bool _groupedRows;
+ private int _hoverRow = -1;
+ private ContextMenuStrip _selectionMenu;
+
private EditorSession _session;
private bool _updating;
@@ -48,6 +51,7 @@ public InspectorPanel()
private void InspectorPanel_Disposed(object sender, EventArgs e)
{
+ _selectionMenu?.Dispose();
if (_session != null)
{
_session.SelectionChanged -= Session_SelectionChanged;
@@ -132,6 +136,134 @@ private void GroupHideButton_Click(object sender, EventArgs e)
_session?.HideSelection();
}
+ private void SelectionUnselectButton_Click(object sender, EventArgs e)
+ {
+ UnselectRows();
+ }
+
+ private void SelectionKeepButton_Click(object sender, EventArgs e)
+ {
+ KeepRows(SelectedRowTiles());
+ }
+
+ private void GroupById_CheckedChanged(object sender, EventArgs e)
+ {
+ RebuildSelectionList();
+ }
+
+ private void Group_Resize(object sender, EventArgs e)
+ {
+ // Sized by hand: the list fills the rest of the panel below the buttons.
+ _selectionList.Size = new Size(Math.Max(40, _group.ClientSize.Width - 8),
+ Math.Max(40, _group.ClientSize.Height - _selectionList.Top - 4));
+ }
+
+ private void SelectionList_RetrieveVirtualItem(object sender, RetrieveVirtualItemEventArgs e)
+ {
+ MultiTile[] row = e.ItemIndex < _selectionRows.Count ? _selectionRows[e.ItemIndex] : Array.Empty();
+ if (row.Length == 0)
+ {
+ e.Item = new ListViewItem(string.Empty);
+ e.Item.SubItems.Add(string.Empty);
+ e.Item.SubItems.Add(string.Empty);
+ e.Item.SubItems.Add(string.Empty);
+ return;
+ }
+
+ MultiTile first = row[0];
+ if (_groupedRows)
+ {
+ int min = row.Min(t => t.Z);
+ int max = row.Max(t => t.Z);
+ e.Item = new ListViewItem($"0x{first.Id:X4}", IconFor(first.Id));
+ e.Item.SubItems.Add(row.Length.ToString("N0"));
+ e.Item.SubItems.Add(min == max ? min.ToString() : $"{min}–{max}");
+ }
+ else
+ {
+ e.Item = new ListViewItem(first.Invisible ? $"0x{first.Id:X4} *" : $"0x{first.Id:X4}", IconFor(first.Id));
+ e.Item.SubItems.Add(first.Z.ToString());
+ e.Item.SubItems.Add($"{first.X}, {first.Y}");
+ }
+
+ e.Item.SubItems.Add(TileName(first.Id));
+ }
+
+ private void SelectionList_SelectedIndexChanged(object sender, EventArgs e)
+ {
+ UpdateListHighlight();
+ }
+
+ // Shift ranges in a virtual list.
+ private void SelectionList_VirtualItemsSelectionRangeChanged(object sender, ListViewVirtualItemsSelectionRangeChangedEventArgs e)
+ {
+ UpdateListHighlight();
+ }
+
+ private void SelectionList_DoubleClick(object sender, EventArgs e)
+ {
+ int index = _selectionList.FocusedItem?.Index ?? -1;
+ if (index >= 0 && index < _selectionRows.Count)
+ {
+ KeepRows(_selectionRows[index]);
+ }
+ }
+
+ private void SelectionList_KeyDown(object sender, KeyEventArgs e)
+ {
+ if (e.KeyCode == Keys.Delete)
+ {
+ UnselectRows();
+ e.Handled = true;
+ }
+ else if (e.KeyCode == Keys.Enter)
+ {
+ KeepRows(SelectedRowTiles());
+ e.Handled = e.SuppressKeyPress = true;
+ }
+ else if (e.KeyCode == Keys.A && e.Control)
+ {
+ _selectionList.BeginUpdate();
+ for (int i = 0; i < _selectionRows.Count; ++i)
+ {
+ _selectionList.SelectedIndices.Add(i);
+ }
+
+ _selectionList.EndUpdate();
+ e.Handled = e.SuppressKeyPress = true;
+ }
+ }
+
+ private void SelectionList_MouseMove(object sender, MouseEventArgs e)
+ {
+ int row = _selectionList.GetItemAt(e.X, e.Y)?.Index ?? -1;
+ if (row != _hoverRow)
+ {
+ _hoverRow = row;
+ UpdateListHighlight();
+ }
+ }
+
+ private void SelectionList_MouseLeave(object sender, EventArgs e)
+ {
+ _hoverRow = -1;
+ UpdateListHighlight();
+ }
+
+ private void SelectionList_MouseUp(object sender, MouseEventArgs e)
+ {
+ if (e.Button != MouseButtons.Right || _selectionList.SelectedIndices.Count == 0)
+ {
+ return;
+ }
+
+ _selectionMenu ??= new ContextMenuStrip();
+ _selectionMenu.Items.Clear();
+ _selectionMenu.Items.Add("Unselect", null, (_, _) => UnselectRows());
+ _selectionMenu.Items.Add("Keep only these", null, (_, _) => KeepRows(SelectedRowTiles()));
+ _selectionMenu.Show(_selectionList, e.Location);
+ }
+
private void FloorList_SelectedIndexChanged(object sender, EventArgs e)
{
OnFloorChosen();
@@ -193,6 +325,7 @@ public void Bind(EditorSession session)
{
if (_session != null)
{
+ _session.ListHighlight = null;
_session.SelectionChanged -= Session_SelectionChanged;
_session.DocumentChanged -= Session_DocumentChanged;
_session.ViewChanged -= Session_ViewChanged;
@@ -264,6 +397,8 @@ private void RefreshSelection()
_groupLabel.Text = $"{count} tiles selected, Z {min}–{max}";
_groupZ.Value = Math.Clamp(min, (int)_groupZ.Minimum, (int)_groupZ.Maximum);
}
+
+ RebuildSelectionList();
}
finally
{
@@ -271,13 +406,109 @@ private void RefreshSelection()
}
}
+ ///
+ /// Fills the list of selected tiles (several only): one row per tile, top Z first, or one
+ /// row per tile id with the most used first.
+ ///
+ private void RebuildSelectionList()
+ {
+ _selectionRows.Clear();
+ _groupedRows = _groupById.Checked;
+ if (_session != null && _session.Selection.Count > 1)
+ {
+ IEnumerable sorted = _session.Selection.OrderByDescending(t => t.Z).ThenBy(t => t.Y).ThenBy(t => t.X);
+ if (_groupedRows)
+ {
+ _selectionRows.AddRange(sorted.GroupBy(t => t.Id).OrderByDescending(g => g.Count()).ThenBy(g => g.Key).Select(g => g.ToArray()));
+ }
+ else
+ {
+ _selectionRows.AddRange(sorted.Select(t => new[] { t }));
+ }
+ }
+
+ selectionColumnZ.Text = _groupedRows ? "Count" : "Z";
+ selectionColumnWhere.Text = _groupedRows ? "Z" : "X, Y";
+ _hoverRow = -1;
+ _selectionList.SelectedIndices.Clear();
+ _selectionList.VirtualListSize = _selectionRows.Count;
+ _selectionList.Invalidate();
+ UpdateListHighlight();
+ }
+
+ private List SelectedRowTiles()
+ {
+ var tiles = new List();
+ foreach (int index in _selectionList.SelectedIndices)
+ {
+ if (index < _selectionRows.Count)
+ {
+ tiles.AddRange(_selectionRows[index]);
+ }
+ }
+
+ return tiles;
+ }
+
+ /// The tiles of the row under the mouse glow on the canvas, else those of the chosen rows.
+ private void UpdateListHighlight()
+ {
+ if (_session == null)
+ {
+ return;
+ }
+
+ _session.ListHighlight = _hoverRow >= 0 && _hoverRow < _selectionRows.Count ? _selectionRows[_hoverRow] : SelectedRowTiles();
+ }
+
+ /// Takes the chosen rows out of the selection and moves to the row that took their place.
+ private void UnselectRows()
+ {
+ List tiles = SelectedRowTiles();
+ if (_session == null || tiles.Count == 0)
+ {
+ return;
+ }
+
+ int first = _selectionList.SelectedIndices[0];
+ _session.Deselect(tiles);
+ if (_selectionRows.Count > 0)
+ {
+ int index = Math.Min(first, _selectionRows.Count - 1);
+ _selectionList.SelectedIndices.Add(index);
+ _selectionList.FocusedItem = _selectionList.Items[index];
+ _selectionList.EnsureVisible(index);
+ }
+ }
+
+ private void KeepRows(IReadOnlyCollection tiles)
+ {
+ if (_session != null && tiles.Count > 0)
+ {
+ // Selecting rebuilds this list, so let the ListView finish its own event first.
+ var keep = tiles.ToList();
+ BeginInvoke(() => _session?.SetSelection(keep, add: false));
+ }
+ }
+
+ private static string TileName(ushort id) => TileBrowserThumbnails.NameOf(id);
+
private void ShowSingle(MultiTile tile)
{
- ItemData data = TileData.ItemTable[tile.Id];
_thumb.Image = Art.GetStatic(tile.Id);
- _idLabel.Text = $"0x{tile.Id:X4} {data.Name}";
- TileFlag flags = data.Flags & _shownFlags;
- _infoLabel.Text = $"Height {data.Height}\r\n{(flags == 0 ? "no flags" : flags.ToString())}";
+ if (TileBrowserThumbnails.HasItemData(tile.Id))
+ {
+ ItemData data = TileData.ItemTable[tile.Id];
+ _idLabel.Text = $"0x{tile.Id:X4} {data.Name}";
+ List flags = TileBrowserThumbnails.BuildFlagNames(data.Flags);
+ string height = (data.Flags & TileFlag.Bridge) != 0 ? $"Height {data.Height} (calc {data.CalcHeight})" : $"Height {data.Height}";
+ _infoLabel.Text = $"{height}\r\n{(flags.Count == 0 ? "no flags" : string.Join(", ", flags))}";
+ }
+ else
+ {
+ _idLabel.Text = $"0x{tile.Id:X4}";
+ _infoLabel.Text = "No tiledata entry";
+ }
_x.Maximum = Math.Max(0, _session.CompList.Width - 1);
_y.Maximum = Math.Max(0, _session.CompList.Height - 1);
@@ -295,10 +526,9 @@ private void ShowSingle(MultiTile tile)
for (int i = cell.Count - 1; i >= 0; --i)
{
MultiTile entry = cell[i];
- ItemData entryData = TileData.ItemTable[entry.Id];
var item = new ListViewItem(entry.Invisible ? $"0x{entry.Id:X4} *" : $"0x{entry.Id:X4}", IconFor(entry.Id)) { Tag = entry };
item.SubItems.Add(entry.Z.ToString());
- item.SubItems.Add(entryData.Name ?? string.Empty);
+ item.SubItems.Add(TileName(entry.Id));
_stack.Items.Add(item);
if (entry == tile)
{
diff --git a/UoFiddler.Plugin.MultiEditor/UI/TileBrowserThumbnails.cs b/UoFiddler.Plugin.MultiEditor/UI/TileBrowserThumbnails.cs
index 8284aa5a..b787b698 100644
--- a/UoFiddler.Plugin.MultiEditor/UI/TileBrowserThumbnails.cs
+++ b/UoFiddler.Plugin.MultiEditor/UI/TileBrowserThumbnails.cs
@@ -27,30 +27,39 @@ internal sealed class TileBrowserThumbnails : IDisposable
// Enough for several screens of 64 px cells; a full clear past it is cheap and rare.
private const int _maxEntries = 3000;
- private static readonly (TileFlag Flag, string Name)[] _summaryFlags =
+ /// The tiledata flags that matter when building a multi, with the names shown for them.
+ private static readonly (TileFlag Flag, string Name)[] _buildFlags =
{
- (TileFlag.Background, "Background"),
- (TileFlag.Wall, "Wall"),
(TileFlag.Impassable, "Impassable"),
+ (TileFlag.Wall, "Wall"),
+ (TileFlag.NoShoot, "No shoot"),
(TileFlag.Surface, "Surface"),
(TileFlag.Bridge, "Bridge"),
- (TileFlag.Roof, "Roof"),
+ (TileFlag.StairBack, "Stair back"),
+ (TileFlag.StairRight, "Stair right"),
(TileFlag.Door, "Door"),
(TileFlag.Window, "Window"),
- (TileFlag.StairBack, "StairBack"),
- (TileFlag.StairRight, "StairRight"),
+ (TileFlag.Roof, "Roof"),
+ (TileFlag.Background, "Background"),
(TileFlag.Foliage, "Foliage"),
- (TileFlag.Container, "Container"),
- (TileFlag.LightSource, "Light"),
- (TileFlag.Translucent, "Translucent"),
- (TileFlag.Transparent, "Transparent"),
- (TileFlag.Animation, "Animated"),
- (TileFlag.Wet, "Wet"),
- (TileFlag.NoShoot, "NoShoot"),
- (TileFlag.HoverOver, "HoverOver"),
- (TileFlag.Generic, "Generic")
+ (TileFlag.HoverOver, "Hover over")
};
+ /// The names of the building flags a tile has, in a fixed order.
+ public static List BuildFlagNames(TileFlag flags)
+ {
+ var names = new List();
+ foreach ((TileFlag flag, string name) in _buildFlags)
+ {
+ if ((flags & flag) != 0)
+ {
+ names.Add(name);
+ }
+ }
+
+ return names;
+ }
+
private readonly Dictionary<(int Id, int Box), Bitmap> _cache = new Dictionary<(int, int), Bitmap>();
/// The art fitted into a box of the given size, or null when the tile has no art.
@@ -137,17 +146,7 @@ public static string Describe(int id)
text.Append(CultureInfo.InvariantCulture, $" (calc {data.CalcHeight})");
}
- text.Append(CultureInfo.InvariantCulture, $", weight {data.Weight}");
-
- var flags = new List();
- foreach ((TileFlag flag, string flagName) in _summaryFlags)
- {
- if ((data.Flags & flag) != 0)
- {
- flags.Add(flagName);
- }
- }
-
+ List flags = BuildFlagNames(data.Flags);
if (flags.Count > 0)
{
text.AppendLine();
From db61e1cb899b5b5f6a2d6feb733bf0afcddcbfe3 Mon Sep 17 00:00:00 2001
From: AsY!um- <377468+AsYlum-@users.noreply.github.com>
Date: Sat, 14 Nov 2026 17:06:13 +0100
Subject: [PATCH 4/5] Zoom 100% button, full shortcuts list, grid under tiles,
400 undo steps
Ctrl+drag on empty space now adds to the selection in the Select tool.
---
.../Classes/MultiEditorComponentList.cs | 2 +-
.../Rendering/SceneRenderer.cs | 26 +++----
.../Tools/SelectTool.cs | 3 +-
.../UI/EditorCommands.cs | 10 ++-
.../UI/MultiEditorControl.cs | 6 +-
.../UI/ShortcutsForm.cs | 70 +++++++++++++++----
6 files changed, 86 insertions(+), 31 deletions(-)
diff --git a/UoFiddler.Plugin.MultiEditor/Classes/MultiEditorComponentList.cs b/UoFiddler.Plugin.MultiEditor/Classes/MultiEditorComponentList.cs
index f792c388..0d7ec153 100644
--- a/UoFiddler.Plugin.MultiEditor/Classes/MultiEditorComponentList.cs
+++ b/UoFiddler.Plugin.MultiEditor/Classes/MultiEditorComponentList.cs
@@ -23,7 +23,7 @@ namespace UoFiddler.Plugin.MultiEditor.Classes
///
internal partial class MultiEditorComponentList
{
- public const int DefaultUndoStackSize = 200;
+ public const int DefaultUndoStackSize = 400;
public const int GapXMod = 44;
public const int GapYMod = 22;
diff --git a/UoFiddler.Plugin.MultiEditor/Rendering/SceneRenderer.cs b/UoFiddler.Plugin.MultiEditor/Rendering/SceneRenderer.cs
index 7b005911..096d5bfd 100644
--- a/UoFiddler.Plugin.MultiEditor/Rendering/SceneRenderer.cs
+++ b/UoFiddler.Plugin.MultiEditor/Rendering/SceneRenderer.cs
@@ -17,8 +17,8 @@
namespace UoFiddler.Plugin.MultiEditor.Rendering
{
///
- /// Turns an editor session into sprites and lines: the multi in draw order with its view
- /// highlights, the grid, then what the active tool shows.
+ /// Turns an editor session into sprites and lines: the ground grid and outline, the multi in
+ /// draw order with its view highlights over them, then what the active tool shows.
///
internal sealed class SceneRenderer : IDisposable, IToolOverlay
{
@@ -104,21 +104,23 @@ public void Draw(EditorSession session, Camera2D camera, Size viewport)
if (session.PlanMode)
{
AddPlan(session);
+ LastSpriteCount += _sprites.Count;
+ _sprites.Flush(_atlas, camera, viewport);
+ _lines.Flush(camera, viewport);
}
else
{
- AddTiles(session);
- }
-
- LastSpriteCount += _sprites.Count;
- _sprites.Flush(_atlas, camera, viewport);
-
- if (!session.PlanMode)
- {
+ // The grid and the outline lie on the ground (Z 0): drawn after the reference
+ // underlay but before the tiles, so whatever stands on the ground covers them.
+ LastSpriteCount += _sprites.Count;
+ _sprites.Flush(_atlas, camera, viewport);
AddGrid(session);
- }
+ _lines.Flush(camera, viewport);
- _lines.Flush(camera, viewport);
+ AddTiles(session);
+ LastSpriteCount += _sprites.Count;
+ _sprites.Flush(_atlas, camera, viewport);
+ }
// Tool overlay: sprites first (ghost tiles), then the lines on top of them.
session.ActiveTool.PaintOverlay(this);
diff --git a/UoFiddler.Plugin.MultiEditor/Tools/SelectTool.cs b/UoFiddler.Plugin.MultiEditor/Tools/SelectTool.cs
index 1afc6312..1117c30d 100644
--- a/UoFiddler.Plugin.MultiEditor/Tools/SelectTool.cs
+++ b/UoFiddler.Plugin.MultiEditor/Tools/SelectTool.cs
@@ -47,8 +47,9 @@ public override void OnMouseDown(MouseEventArgs e)
_start = Context.MouseLoc;
_moveDx = _moveDy = 0;
}
- else if (hover == null && !CtrlHeld)
+ else if (hover == null)
{
+ // With Ctrl the box adds to the selection when it is let go.
_marquee = true;
_start = _end = Context.MouseLoc;
}
diff --git a/UoFiddler.Plugin.MultiEditor/UI/EditorCommands.cs b/UoFiddler.Plugin.MultiEditor/UI/EditorCommands.cs
index 8d651b88..fa13537f 100644
--- a/UoFiddler.Plugin.MultiEditor/UI/EditorCommands.cs
+++ b/UoFiddler.Plugin.MultiEditor/UI/EditorCommands.cs
@@ -279,6 +279,9 @@ internal static class Glyphs
public const string ZoomOut = "\uE71F";
public const string Fit = "\uE9A6";
public const string Center = "\uE81D";
+
+ /// Zoom 100%: Segoe MDL2 has no actual-size icon, so it is the text "1:1".
+ public const string ActualSize = "1:1";
public const string RotateLeft = "\uE7AD";
public const string RotateRight = "\uE7AD";
public const string Help = "\uE897";
@@ -288,7 +291,12 @@ public static Bitmap Render(string glyph, int size, Color color)
var bitmap = new Bitmap(size, size);
using Graphics g = Graphics.FromImage(bitmap);
g.TextRenderingHint = System.Drawing.Text.TextRenderingHint.AntiAlias;
- using var font = new Font("Segoe MDL2 Assets", size * 0.72f, GraphicsUnit.Pixel);
+
+ // Icons are private-use characters of the icon font; anything else is short text drawn in a UI font.
+ bool icon = glyph.Length > 0 && glyph[0] >= '' && glyph[0] <= '';
+ using var font = icon
+ ? new Font("Segoe MDL2 Assets", size * 0.72f, GraphicsUnit.Pixel)
+ : new Font("Segoe UI", size * 0.55f, FontStyle.Bold, GraphicsUnit.Pixel);
using var brush = new SolidBrush(color);
using var format = new StringFormat { Alignment = StringAlignment.Center, LineAlignment = StringAlignment.Center };
g.DrawString(glyph, font, brush, new RectangleF(0, 0, size, size), format);
diff --git a/UoFiddler.Plugin.MultiEditor/UI/MultiEditorControl.cs b/UoFiddler.Plugin.MultiEditor/UI/MultiEditorControl.cs
index ee97834f..185d338d 100644
--- a/UoFiddler.Plugin.MultiEditor/UI/MultiEditorControl.cs
+++ b/UoFiddler.Plugin.MultiEditor/UI/MultiEditorControl.cs
@@ -540,7 +540,7 @@ private void DefineCommands()
_center = Command("View", "Center design", () => _canvas.CenterView(), Keys.Shift | Keys.Home, hasDoc, glyph: Glyphs.Center);
_zoomIn = Command("View", "Zoom in", () => _canvas.ZoomStep(1), Keys.Add, glyph: Glyphs.ZoomIn, alt: Keys.Shift | Keys.Oemplus);
_zoomOut = Command("View", "Zoom out", () => _canvas.ZoomStep(-1), Keys.Subtract, glyph: Glyphs.ZoomOut, alt: Keys.OemMinus);
- _zoomReset = Command("View", "Zoom 100%", () => _canvas.SetZoom(1f), Keys.Control | Keys.D0, alt: Keys.Control | Keys.NumPad0);
+ _zoomReset = Command("View", "Zoom 100%", () => _canvas.SetZoom(1f), Keys.Control | Keys.D0, glyph: Glyphs.ActualSize, alt: Keys.Control | Keys.NumPad0);
_performanceInfo = Command("View", "Performance info", TogglePerformanceInfo, Keys.F12, isChecked: () => _performanceShown);
AddTool(ToolKind.Select, "Select", Keys.S, Glyphs.Select);
@@ -1173,12 +1173,12 @@ private void BuildMainStrip()
InsertButtons(strip, _mainSeparator3, _cut, _copy, _paste, _delete);
InsertButtons(strip, _mainSeparator4, _workPlane, _grid, _hideWalls);
- // Zoom, then help, after the other groups: zoom out, the zoom, zoom in, fit and centre.
+ // Zoom, then help, after the other groups: zoom out, the zoom, zoom in, 100%, fit and centre.
int at = strip.Items.IndexOf(_zoomLabel);
strip.Items.Insert(at++, new ToolStripSeparator());
strip.Items.Insert(at++, _commands.Button(_zoomOut));
at++; // the zoom label
- foreach (EditorCommand command in new[] { _zoomIn, _fit, _center })
+ foreach (EditorCommand command in new[] { _zoomIn, _zoomReset, _fit, _center })
{
strip.Items.Insert(at++, _commands.Button(command));
}
diff --git a/UoFiddler.Plugin.MultiEditor/UI/ShortcutsForm.cs b/UoFiddler.Plugin.MultiEditor/UI/ShortcutsForm.cs
index 3960d916..2699fb70 100644
--- a/UoFiddler.Plugin.MultiEditor/UI/ShortcutsForm.cs
+++ b/UoFiddler.Plugin.MultiEditor/UI/ShortcutsForm.cs
@@ -16,8 +16,9 @@
namespace UoFiddler.Plugin.MultiEditor.UI
{
///
- /// The list of keyboard shortcuts and mouse actions (F1). The rows come from the editor's
- /// command registry, so the list always matches the keys that work.
+ /// The list of keyboard shortcuts and mouse actions (F1). The command rows come from the
+ /// editor's command registry, so they always match the keys that work; the mouse, tool and
+ /// sidebar rows are written here.
///
internal partial class ShortcutsForm : Form
{
@@ -43,17 +44,60 @@ public void ShowCommands(IEnumerable commands)
}
}
- Heading("Mouse");
- foreach ((string what, string how) in new[]
- {
- ("Pan", "Right or middle drag, Space + drag"),
- ("Zoom at the cursor", "Wheel"),
- ("Step through the floors", "Shift + wheel"),
- ("Context menu, or cancel a drag or paste", "Right click"),
- ("Select the tile under the cursor (any tool)", "Middle click"),
- ("Move the selection one cell", "Arrow keys"),
- ("Pan one cell", "Ctrl + arrow keys")
- })
+ // Keys handled outside the command registry: kept here by hand, so check them when a
+ // tool or a sidebar list gets a new key.
+ Section("Mouse",
+ ("Pan", "Right or middle drag, Space + drag"),
+ ("Zoom at the cursor", "Wheel"),
+ ("Step through the floors", "Shift + wheel"),
+ ("Context menu, or cancel a drag or paste", "Right click"),
+ ("Select the tile under the cursor (any tool)", "Middle click"),
+ ("Move the selection one cell", "Arrow keys"),
+ ("Pan one cell", "Ctrl + arrow keys"));
+
+ Section("Keys held with a tool",
+ ("Move the selection (Select)", "Drag a selected tile"),
+ ("Add a tile to the selection or take it out (Select)", "Ctrl + click"),
+ ("Add to the selection (Select, Lasso)", "Ctrl + drag"),
+ ("Keep the line straight (Line)", "Shift + drag"),
+ ("One straight wall instead of a room (Walls)", "Shift + drag"),
+ ("Erase walls (Walls)", "Ctrl + drag"),
+ ("Fit the roof to the walls (Roof)", "Click without dragging"),
+ ("Turn the ridge the other way (Roof)", "Shift"),
+ ("Gables at one end or both (Roof)", "Ctrl"));
+
+ Section("Tile browser",
+ ("Go from the search box to the results", "Down"),
+ ("Pick the first result", "Enter in the search box"),
+ ("Clear the search", "Esc in the search box"),
+ ("Move through the tiles", "Arrow keys, Home, End, Page Up, Page Down"),
+ ("Pick the tile", "Enter, click"),
+ ("Remove a tile from the favorites page", "Delete"),
+ ("Place a blueprint", "Click, Enter"),
+ ("Rename a blueprint", "F2"),
+ ("Delete a blueprint", "Delete"),
+ ("Open UO Architect designs (Designs page)", "Enter, double click"));
+
+ Section("Selected tiles list",
+ ("Take the chosen rows out of the selection", "Delete"),
+ ("Keep only the chosen rows selected", "Enter"),
+ ("Keep only this row selected", "Double click"),
+ ("Choose every row", "Ctrl + A"));
+
+ // The keys column fits its longest entry, and the window grows to show both columns whole.
+ _list.AutoResizeColumn(_keysColumn.Index, ColumnHeaderAutoResizeStyle.ColumnContent);
+ _keysColumn.Width = Math.Max(_keysColumn.Width + 12, 160);
+ int needed = _commandColumn.Width + _keysColumn.Width + SystemInformation.VerticalScrollBarWidth + 8;
+ if (_list.ClientSize.Width < needed)
+ {
+ Width += needed - _list.ClientSize.Width;
+ }
+ }
+
+ private void Section(string heading, params (string What, string How)[] rows)
+ {
+ Heading(heading);
+ foreach ((string what, string how) in rows)
{
Row(what, how);
}
From 3fa9e69c22f43fef82706cd435e43a97bbd3ac6b Mon Sep 17 00:00:00 2001
From: AsY!um- <377468+AsYlum-@users.noreply.github.com>
Date: Sat, 14 Nov 2026 17:23:52 +0100
Subject: [PATCH 5/5] Update change log and version
---
UoFiddler/Forms/AboutBoxForm.resx | 12 +++++++++++-
UoFiddler/UoFiddler.csproj | 6 +++---
2 files changed, 14 insertions(+), 4 deletions(-)
diff --git a/UoFiddler/Forms/AboutBoxForm.resx b/UoFiddler/Forms/AboutBoxForm.resx
index ca2aa07a..46f51168 100644
--- a/UoFiddler/Forms/AboutBoxForm.resx
+++ b/UoFiddler/Forms/AboutBoxForm.resx
@@ -118,7 +118,17 @@
System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
- Version 4.28.0
+ Version 4.28.1
+- Multi Editor: prefabs are now blueprints, kept in MultiEditorBlueprints; an existing MultiEditorPrefabs folder is moved there on first use
+- Multi Editor: save picked multis and UO Architect designs as blueprints in bulk from Open from client, Import files, Import from folder and the Designs page
+- Multi Editor: blueprints keep their name in the file and show their footprint; the Blueprints page has a group drop-down and favorite groups
+- Multi Editor: blueprint previews stack tiles like the canvas, so tree foliage covers the trunk, and are drawn as they scroll into view
+- Multi Editor: imports and exports CentrED# blueprints (CSV and TilesEntry XML)
+- Multi Editor: the inspector lists the selected tiles; rows highlight their tiles on the canvas and can be taken out of the selection
+- Multi Editor: tile tooltips show the height and the building flags only
+- Multi Editor: Zoom 100% toolbar button, every key in the shortcuts list (F1), the grid and outline drawn under the tiles, 400 undo steps, and Ctrl+drag adds a box to the selection
+
+Version 4.28.0
- Multi Editor: replaced by an OpenGL editor (3.0.0) with a tool palette, document tabs, a tile browser with house styles, blueprints and UO Architect designs, floors, a plan view and a preview
- Multi Editor: document tabs have close buttons and Close all designs, and the mouse wheel over the tab strip switches designs
- Multi Editor: one work plane follows the chosen floor and moves a Z at a time with Ctrl+PgUp/PgDn; its Z shows in the status bar
diff --git a/UoFiddler/UoFiddler.csproj b/UoFiddler/UoFiddler.csproj
index c000f846..91bd1aae 100644
--- a/UoFiddler/UoFiddler.csproj
+++ b/UoFiddler/UoFiddler.csproj
@@ -9,9 +9,9 @@
UoFiddler
UoFiddler
Copyright © 2026
- 4.28.0
- 4.28.0
- 4.28.0
+ 4.28.1
+ 4.28.1
+ 4.28.1
true