Files
2026-07-13 13:13:17 +08:00

674 lines
29 KiB
C#

#nullable enable
using System.Diagnostics;
using System.IO;
using System.Threading.Tasks;
using ImGuiNET;
using MsdfAtlasGen;
using Msdfgen;
using Msdfgen.Extensions;
using T3.Core.Model;
using T3.Core.SystemUi;
using T3.Editor.Gui.Input;
using T3.Editor.Gui.Styling;
using T3.Editor.Gui.UiHelpers;
using T3.Editor.UiModel;
using T3.Editor.UiModel.ProjectHandling;
namespace T3.Editor.Gui.Windows.Utilities
{
public static class MsdfGeneration
{
private static bool _useRecommended = true;
private enum ColoringStrategy
{
Simple,
InkTrap
}
private enum ErrorCorrectionMode
{
Disabled,
Indiscriminate,
EdgeOnly,
Auto
}
public enum CharsetMode
{
Ascii,
ExtendedAscii,
Custom,
AllGlyphs
}
#region Default Values
private const float DefaultFontSize = 30;
private const int DefaultWidth = 1024;
private const int DefaultHeight = 1024;
private const float DefaultMiterLimit = 3.0f;
private const int DefaultSpacing = 2;
private const float DefaultRange = 7.0f;
private const float DefaultAngleThreshold = 3.0f;
private const ColoringStrategy DefaultExampleColoring = ColoringStrategy.Simple; // As per recommended settings
private const ErrorCorrectionMode DefaultErrorCorrection = ErrorCorrectionMode.Indiscriminate;
private const bool DefaultOverlap = true;
private static readonly Vector4 DefaultPadding = Vector4.Zero;
#endregion
// Settings fields
private static ColoringStrategy _coloringStrategy = DefaultExampleColoring;
private static ErrorCorrectionMode _errorCorrection = DefaultErrorCorrection;
private static CharsetMode _charsetMode = CharsetMode.Ascii;
private static bool _overlap = DefaultOverlap;
private static Vector4 _outerPadding = DefaultPadding;
private static float _fontSize = DefaultFontSize;
private static int _width = DefaultWidth;
private static int _height = DefaultHeight;
private static float _miterLimit = DefaultMiterLimit;
private static int _spacing = DefaultSpacing;
private static float _rangeValue = DefaultRange;
private static float _angleThreshold = DefaultAngleThreshold;
private static string? _customCharsetPath = null;
private static string? _fontFilePath = null;
private static SymbolPackage? _selectedPackage;
// Status & Progress
private static bool _isGenerating;
private static float _generationProgress;
private static string _progressText = "";
private static string _statusMessage = "";
private static bool _isStatusError = false;
private static string _lastOutputDir = "";
// Metrics
private class GenerationMetrics
{
public TimeSpan LoadTime;
public TimeSpan CharsetTime;
public TimeSpan ColoringTime;
public TimeSpan PackTime;
public TimeSpan GenerateTime;
public TimeSpan SaveTime;
public TimeSpan TotalTime;
}
private static GenerationMetrics? _lastMetrics;
public static void Draw()
{
FormInputs.SetIndent(50 * T3Ui.UiScaleFactor);
FormInputs.AddSectionHeader("MSDF Generation");
CustomComponents.HelpText("Generate MSDF fonts from .ttf/.otf files using Msdfgen.\nChecks for 'Resources/fonts'.");
FormInputs.AddVerticalSpace();
if (_isGenerating)
{
ImGui.BeginDisabled();
}
// Font File
FormInputs.AddFilePicker("Font File", ref _fontFilePath, null, null, "Select .ttf/.otf file", FileOperations.FilePickerTypes.File);
// Recommended Toggle
FormInputs.AddCheckBox("Use Recommended Settings", ref _useRecommended);
if (!_useRecommended)
{
FormInputs.SetIndent(100 * T3Ui.UiScaleFactor);
// Size
DrawSettingWithReset("Size", ref _fontSize, DefaultFontSize, 1, 500, 1);
// Dimensions
DrawSettingWithReset("Width", ref _width, DefaultWidth, 128, 8192, 128);
DrawSettingWithReset("Height", ref _height, DefaultHeight, 128, 8192, 128);
// Generator Params
DrawSettingWithReset("Miter Limit", ref _miterLimit, DefaultMiterLimit, 0, 10, 0.1f);
DrawSettingWithReset("Spacing", ref _spacing, DefaultSpacing, 0, 32, 1);
DrawSettingWithReset("Range", ref _rangeValue, DefaultRange, 0.1f, 20, 0.1f);
DrawSettingWithReset("Angle Thres.", ref _angleThreshold, DefaultAngleThreshold, 0, 6, 0.1f);
// Enums and Bools
DrawEnumWithReset(ref _coloringStrategy, "Coloring Config.", DefaultExampleColoring);
DrawEnumWithReset(ref _errorCorrection, "Error Correction", DefaultErrorCorrection);
DrawBoolWithReset("Overlap Support", ref _overlap, DefaultOverlap);
// Charset
FormInputs.AddEnumDropdown(ref _charsetMode, "Charset");
if (_charsetMode == CharsetMode.Custom)
{
FormInputs.AddFilePicker("Charset File", ref _customCharsetPath, null, null, "Select .txt file", FileOperations.FilePickerTypes.File);
}
// Padding
FormInputs.DrawFieldSetHeader("Outer Padding");
var padding = _outerPadding;
var changed = FormInputs.AddFloat("Top", ref padding.X, 0, 100, 1);
changed |= FormInputs.AddFloat("Right", ref padding.Y, 0, 100, 1);
changed |= FormInputs.AddFloat("Bottom", ref padding.Z, 0, 100, 1);
changed |= FormInputs.AddFloat("Left", ref padding.W, 0, 100, 1);
if (changed) _outerPadding = padding;
// Reset Padding Button
if (_outerPadding != Vector4.Zero)
{
ImGui.SameLine();
if (ImGui.Button("Reset##Padding")) _outerPadding = Vector4.Zero;
}
FormInputs.ApplyIndent();
}
FormInputs.AddVerticalSpace();
FormInputs.SetIndent(90 * T3Ui.UiScaleFactor);
// Project Selection
DrawProjectSelection(out var usagePackage);
// Generate Button
bool hasFile = !string.IsNullOrEmpty(_fontFilePath) && File.Exists(_fontFilePath);
bool hasCharset = _charsetMode != CharsetMode.Custom || (!string.IsNullOrEmpty(_customCharsetPath) && File.Exists(_customCharsetPath));
if (CustomComponents.DisablableButton("Generate MSDF", hasFile && hasCharset && usagePackage != null && !_isGenerating))
{
GenerateAsync(usagePackage!).ContinueWith(t =>
{
if (t.Exception != null)
{
Log.Error("Unhandled exception in MSDF generation: " + t.Exception);
_statusMessage = $"Error: {t.Exception.Message}";
_isStatusError = true;
_isGenerating = false;
}
}, TaskContinuationOptions.OnlyOnFaulted);
}
if (_isGenerating)
{
ImGui.EndDisabled();
}
// Validations
if (!hasFile) CustomComponents.TooltipForLastItem("Please select a valid .ttf/.otf font file.");
else if (!hasCharset) CustomComponents.TooltipForLastItem("Please select a valid charset .txt file.");
else if (usagePackage == null) CustomComponents.TooltipForLastItem("Please select a target project.");
// Progress Bar
if (_isGenerating)
{
FormInputs.AddVerticalSpace();
ImGui.PushStyleColor(ImGuiCol.PlotHistogram, UiColors.StatusAutomated.Rgba);
ImGui.ProgressBar(_generationProgress, new System.Numerics.Vector2(-1, 4), "");
ImGui.PopStyleColor();
ImGui.Text(_progressText);
FormInputs.AddVerticalSpace();
}
// Status and Results
if (!string.IsNullOrEmpty(_statusMessage))
{
var color = _isStatusError ? UiColors.StatusError : UiColors.StatusAutomated;
ImGui.TextColored(color, _statusMessage);
// Performance Tooltip on Success
if (!_isStatusError && _lastMetrics != null && ImGui.IsItemHovered())
{
ImGui.BeginTooltip();
ImGui.Text("Performance Metrics:");
ImGui.Text($"Load Font: {_lastMetrics.LoadTime.TotalSeconds:F3}s");
ImGui.Text($"Charset: {_lastMetrics.CharsetTime.TotalSeconds:F3}s");
ImGui.Text($"Coloring: {_lastMetrics.ColoringTime.TotalSeconds:F3}s");
ImGui.Text($"Packing: {_lastMetrics.PackTime.TotalSeconds:F3}s");
ImGui.Text($"Generate: {_lastMetrics.GenerateTime.TotalSeconds:F3}s");
ImGui.Text($"Saving: {_lastMetrics.SaveTime.TotalSeconds:F3}s");
ImGui.Separator();
ImGui.Text($"Total: {_lastMetrics.TotalTime.TotalSeconds:F3}s");
ImGui.EndTooltip();
}
if (!_isStatusError && !_isGenerating && !string.IsNullOrEmpty(_lastOutputDir))
{
if (ImGui.Button("Open Output Folder"))
{
CoreUi.Instance.OpenWithDefaultApplication(_lastOutputDir);
}
if (ImGui.IsItemHovered())
{
ImGui.SetTooltip("Click to open the folder containing the generated assets.");
}
}
}
}
private static void DrawProjectSelection(out SymbolPackage? usagePackage)
{
var internalPackage = GetPackageContainingPath(_fontFilePath);
usagePackage = null;
if (internalPackage != null)
{
var name = internalPackage.DisplayName;
ImGui.BeginDisabled();
FormInputs.AddStringInput("Target Project", ref name);
ImGui.EndDisabled();
usagePackage = internalPackage;
}
else
{
var editablePackages = EditableSymbolProject.AllProjects.ToList();
if (_selectedPackage == null || !editablePackages.Contains(_selectedPackage))
{
var focusedProject = ProjectView.Focused?.OpenedProject.Package;
_selectedPackage = focusedProject != null && !focusedProject.IsReadOnly
? focusedProject
: editablePackages.FirstOrDefault();
}
var packageNames = editablePackages.Select(p => p.DisplayName).OrderBy(n => n);
var selectedName = _selectedPackage?.DisplayName ?? "";
if (FormInputs.AddStringInputWithSuggestions("Target Project", ref selectedName, packageNames, "Select Project"))
{
_selectedPackage = editablePackages.FirstOrDefault(p => p.DisplayName == selectedName);
}
if (_selectedPackage == null || _selectedPackage.DisplayName != selectedName)
{
var match = editablePackages.FirstOrDefault(p => p.DisplayName == selectedName);
if (match != null) _selectedPackage = match;
}
usagePackage = _selectedPackage;
if (usagePackage == null)
{
ImGui.Indent(150 * T3Ui.UiScaleFactor);
ImGui.TextColored(UiColors.StatusError, "Invalid or No Project selected.");
ImGui.Unindent(150 * T3Ui.UiScaleFactor);
}
}
}
private static void DrawSettingWithReset<T>(string label, ref T value, T defaultValue, float min, float max, float step = 1)
where T : struct, IConvertible
{
if (typeof(T) == typeof(int))
{
int val = Convert.ToInt32(value);
if (FormInputs.AddInt(label, ref val, (int)min, (int)max, (int)step)) value = (T)Convert.ChangeType(val, typeof(T));
}
else if (typeof(T) == typeof(float))
{
float val = Convert.ToSingle(value);
if (FormInputs.AddFloat(label, ref val, min, max, step)) value = (T)Convert.ChangeType(val, typeof(T));
}
if (!EqualityComparer<T>.Default.Equals(value, defaultValue))
{
ImGui.SameLine();
ImGui.PushID(label);
if (CustomComponents.IconButton(Icon.Revert, new System.Numerics.Vector2(ImGui.GetFrameHeight())))
{
value = defaultValue;
}
ImGui.PopID();
}
}
private static void DrawBoolWithReset(string label, ref bool value, bool defaultValue)
{
FormInputs.AddCheckBox(label, ref value);
if (value != defaultValue)
{
ImGui.SameLine();
ImGui.PushID(label);
if (CustomComponents.IconButton(Icon.Revert, new System.Numerics.Vector2(ImGui.GetFrameHeight())))
{
value = defaultValue;
}
ImGui.PopID();
}
}
private static void DrawEnumWithReset<T>(ref T value, string label, T defaultValue) where T : struct, Enum
{
FormInputs.AddEnumDropdown(ref value, label);
if (!EqualityComparer<T>.Default.Equals(value, defaultValue))
{
ImGui.SameLine();
ImGui.PushID(label);
if (CustomComponents.IconButton(Icon.Revert, new System.Numerics.Vector2(ImGui.GetFrameHeight())))
{
value = defaultValue;
}
ImGui.PopID();
}
}
public static void RefreshSelection()
{
_selectedPackage = null;
}
private record GenerationResult(
bool Success,
string Message = "",
GenerationMetrics? Metrics = null,
string OutputDir = "");
private static async Task GenerateAsync(SymbolPackage package)
{
// Initialize UI State
_statusMessage = "";
_isStatusError = false;
_isGenerating = true;
_generationProgress = 0;
_progressText = "Starting generation...";
_lastMetrics = new GenerationMetrics();
// 1. Pre-flight check (Avoids starting a Task if basic requirements aren't met)
if (string.IsNullOrEmpty(_fontFilePath) || !File.Exists(_fontFilePath))
{
_statusMessage = $"Error: Font file not found at {_fontFilePath}";
_isStatusError = true;
_isGenerating = false;
return;
}
var progress = new Progress<GeneratorProgress>(p =>
{
_generationProgress = (float)p.Proportion;
_progressText = $"Generating: {p.Current}/{p.Total} ({p.GlyphName})";
});
// 2. Run logic on background thread
var result = await Task.Run(() => PerformGeneration(package, progress));
// 3. Handle result
if (result.Success)
{
_lastMetrics = result.Metrics;
_lastOutputDir = result.OutputDir;
_statusMessage = $"Success! ({_lastMetrics?.TotalTime.TotalSeconds:F2}s)";
Log.Debug($"MSDF Generation successful!");
}
else
{
_statusMessage = $"Error: {result.Message}";
_isStatusError = true;
Log.Error($"MSDF Generation Failed: {result.Message}");
}
_isGenerating = false;
_progressText = "";
}
private static GenerationResult PerformGeneration(SymbolPackage package, IProgress<GeneratorProgress> progress)
{
var metrics = new GenerationMetrics();
var sw = Stopwatch.StartNew();
var settings = GetSettings();
var outputDir = PrepareOutputDirectory(package);
var fontName = Path.GetFileNameWithoutExtension(settings.FontPath);
var imageOut = Path.Combine(outputDir, $"{fontName}_msdf.png");
var fontOut = Path.Combine(outputDir, $"{fontName}_msdf.fnt");
// 1. Initialize & Load
using var ft = FreetypeHandle.Initialize();
if (ft is null)
return new GenerationResult(false, "Failed to initialize FreeType.");
using var fontHandle = FontHandle.LoadFont(ft, settings.FontPath);
if (fontHandle is null)
return new GenerationResult(false, "Failed to load font.");
metrics.LoadTime = sw.Elapsed;
sw.Restart();
// 2. Charset
var charset = LoadCharset(settings, fontHandle);
var fontGeometry = new FontGeometry();
fontGeometry.LoadCharset(fontHandle, settings.FontSize, charset);
fontGeometry.SetName(fontName);
metrics.CharsetTime = sw.Elapsed;
sw.Restart();
// 3. Coloring
var glyphs = fontGeometry.GetGlyphs().Glyphs.ToArray();
var strategy = settings.Strategy == ColoringStrategy.Simple
? Msdfgen.EdgeColoring.EdgeColoringSimple
: (EdgeColoringDelegate)Msdfgen.EdgeColoring.EdgeColoringInkTrap;
foreach (var glyph in glyphs)
{
glyph.EdgeColoring(strategy, settings.AngleThreshold, 0);
}
metrics.ColoringTime = sw.Elapsed;
sw.Restart();
// 4. Packing
if (!TryPackGlyphs(glyphs, settings, out var finalW, out var finalH, out var packerScale))
{
return new GenerationResult(false, $"Packing {glyphs.Length} glyphs failed. Try increasing resolution.");
}
metrics.PackTime = sw.Elapsed;
sw.Restart();
// 5. Generation
var generator = GenerateAtlas(glyphs, finalW, finalH, settings, progress);
metrics.GenerateTime = sw.Elapsed;
sw.Restart();
// 6. Saving
SaveResults(generator, fontGeometry, settings, imageOut, fontOut, finalW, finalH, packerScale);
metrics.SaveTime = sw.Elapsed;
metrics.TotalTime = metrics.LoadTime + metrics.CharsetTime + metrics.ColoringTime + metrics.PackTime + metrics.GenerateTime + metrics.SaveTime;
return new GenerationResult(true, Metrics: metrics, OutputDir: outputDir);
}
private static Charset LoadCharset(GenerationSettings settings, FontHandle fontHandle)
{
if (settings.CharsetMode == CharsetMode.AllGlyphs)
{
var charset = new Charset();
if (FontLoader.GetAvailableCodepoints(out var codepoints, fontHandle))
{
foreach (var cp in codepoints) charset.Add(cp);
}
return charset;
}
if (settings.CharsetMode == CharsetMode.ExtendedAscii) return Charset.EASCII;
if (settings.CharsetMode == CharsetMode.Custom && File.Exists(settings.CustomCharsetPath))
{
var charset = new Charset();
var text = File.ReadAllText(settings.CustomCharsetPath);
foreach (var c in text)
if (!char.IsControl(c))
charset.Add(c);
return charset;
}
return Charset.ASCII;
}
private static GenerationSettings GetSettings()
{
// If recommended, enforce specific defaults but maybe keep some flexibility if desired?
// The request implies recommended should just be standard defaults.
if (_useRecommended)
{
return new GenerationSettings
{
FontPath = _fontFilePath ?? string.Empty,
FontSize = 90.0, // Original recommended value
Width = 1024,
Height = 1024,
MiterLimit = 3.0,
Spacing = 2,
RangeValue = 2.0,
AngleThreshold = 3.0,
Strategy = ColoringStrategy.Simple,
ErrorCorrection = ErrorCorrectionMode.Indiscriminate,
Overlap = true,
OuterPadding = new MsdfAtlasGen.Padding(0, 0, 0, 0),
CharsetMode = CharsetMode.Ascii
};
}
return new GenerationSettings
{
FontPath = _fontFilePath ?? string.Empty,
FontSize = _fontSize,
Width = _width,
Height = _height,
MiterLimit = _miterLimit,
Spacing = _spacing,
RangeValue = _rangeValue,
AngleThreshold = _angleThreshold,
Strategy = _coloringStrategy,
ErrorCorrection = _errorCorrection,
Overlap = _overlap,
OuterPadding = new MsdfAtlasGen.Padding((int)_outerPadding.W, (int)_outerPadding.Z, (int)_outerPadding.Y, (int)_outerPadding.X),
CharsetMode = _charsetMode,
CustomCharsetPath = _customCharsetPath ?? string.Empty
};
}
// ... (TryPackGlyphs, GenerateAtlas, SaveResults, PrepareOutputDirectory, IsPathUnderFolder, GetPackageContainingPath same as before with minor tweaks for new types)
private static bool TryPackGlyphs(GlyphGeometry[] glyphs, GenerationSettings settings, out int finalW, out int finalH, out double packerScale)
{
var packer = new TightAtlasPacker();
packer.SetDimensions(settings.Width, settings.Height);
packer.SetMiterLimit(settings.MiterLimit);
packer.SetSpacing(settings.Spacing);
packer.SetPixelRange(new Msdfgen.Range(settings.RangeValue));
packer.SetOuterPixelPadding(settings.OuterPadding);
int packResult = packer.Pack(glyphs);
if (packResult < 0)
{
finalW = 0;
finalH = 0;
packerScale = 0;
return false;
}
packer.GetDimensions(out finalW, out finalH);
packerScale = packer.GetScale();
return true;
}
private static ImmediateAtlasGenerator<float> GenerateAtlas(GlyphGeometry[] glyphs, int width, int height, GenerationSettings settings,
IProgress<GeneratorProgress> progress)
{
var errorMode = settings.ErrorCorrection switch
{
ErrorCorrectionMode.Disabled => ErrorCorrectionConfig.DistanceErrorCorrectionMode.DISABLED,
ErrorCorrectionMode.Indiscriminate => ErrorCorrectionConfig.DistanceErrorCorrectionMode.INDISCRIMINATE,
ErrorCorrectionMode.EdgeOnly => ErrorCorrectionConfig.DistanceErrorCorrectionMode.EDGE_ONLY,
ErrorCorrectionMode.Auto => ErrorCorrectionConfig.DistanceErrorCorrectionMode.AUTO,
_ => ErrorCorrectionConfig.DistanceErrorCorrectionMode.INDISCRIMINATE
};
var generatorConfig = new MSDFGeneratorConfig(settings.Overlap,
new ErrorCorrectionConfig(errorMode,
ErrorCorrectionConfig.DistanceCheckMode.CHECK_DISTANCE_ALWAYS));
// Always 3 channels for MSDF
var generator = new ImmediateAtlasGenerator<float>(width, height, (bitmap, glyph, attrs) =>
{
var proj = glyph.GetBoxProjection();
var gRange = glyph.GetBoxRange();
MsdfGenerator.GenerateMSDF(bitmap, glyph.GetShape()!, proj, gRange,
generatorConfig);
}, 3);
generator.SetThreadCount(Environment.ProcessorCount);
generator.Generate(glyphs, progress);
return generator;
}
private static void SaveResults(ImmediateAtlasGenerator<float> generator, FontGeometry fontGeometry, GenerationSettings settings, string imageOut,
string fontOut, int finalW, int finalH, double packerScale)
{
ImageSaver.Save(generator.AtlasStorage.Bitmap, imageOut);
var metrics = fontGeometry.GetMetrics();
FntExporter.Export([fontGeometry], ImageType.Msdf, finalW, finalH, settings.FontSize, settings.RangeValue,
Path.GetFileName(imageOut), fontOut, metrics, YAxisOrientation.Upward, settings.OuterPadding, settings.Spacing, packerScale);
}
private static string PrepareOutputDirectory(SymbolPackage package)
{
var outputDir = Path.Combine(package.AssetsFolder, "fonts");
if (!Directory.Exists(outputDir)) Directory.CreateDirectory(outputDir);
return outputDir;
}
private static bool IsPathUnderFolder(string fullPath, string? folderPath)
{
if (string.IsNullOrEmpty(fullPath) || string.IsNullOrEmpty(folderPath)) return false;
try
{
fullPath = Path.GetFullPath(fullPath);
folderPath = Path.GetFullPath(folderPath);
}
catch
{
return false;
}
var sep = Path.DirectorySeparatorChar.ToString();
if (!folderPath.EndsWith(sep)) folderPath += sep;
return fullPath.StartsWith(folderPath, StringComparison.OrdinalIgnoreCase);
}
private static SymbolPackage? GetPackageContainingPath(string? fontPath)
{
if (string.IsNullOrWhiteSpace(fontPath)) return null;
try
{
var full = Path.GetFullPath(fontPath);
return SymbolPackage.AllPackages.FirstOrDefault(p => IsPathUnderFolder(full, p.Folder) || IsPathUnderFolder(full, p.AssetsFolder));
}
catch
{
return null;
}
}
private sealed record GenerationSettings
{
public string FontPath { get; init; } = "";
public double FontSize { get; init; }
public int Width { get; init; }
public int Height { get; init; }
public double MiterLimit { get; init; }
public int Spacing { get; init; }
public double RangeValue { get; init; }
public double AngleThreshold { get; init; }
public ColoringStrategy Strategy { get; init; }
public ErrorCorrectionMode ErrorCorrection { get; init; }
public bool Overlap { get; init; }
public MsdfAtlasGen.Padding OuterPadding { get; init; } = new(0, 0, 0, 0);
public CharsetMode CharsetMode { get; init; }
public string CustomCharsetPath { get; init; } = "";
}
}
}