#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(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.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(ref T value, string label, T defaultValue) where T : struct, Enum { FormInputs.AddEnumDropdown(ref value, label); if (!EqualityComparer.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(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 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 GenerateAtlas(GlyphGeometry[] glyphs, int width, int height, GenerationSettings settings, IProgress 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(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 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; } = ""; } } }