diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fd61cb..b66a495 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ All notable changes to this project will be documented in this file. - Added a plugin system. The SDK now defines `IPlugin`, `IPluginContext`, `PluginManifest`, and a `[Plugin(typeof(MyPlugin))]` assembly attribute under `GMConverter.SDK.Plugins`. The core library hosts a `PluginLoader` that discovers `plugin.json` manifests under a `plugins/` directory next to the executable, loads each into its own collectible `AssemblyLoadContext` with SDK type unification (so `IImporter`/`IExporter`/`IExplorer` references match across the plugin boundary), and instantiates the plugin's entry type. Plugin-contributed importers and explorers are merged into the host's resolution path; CLI and UI both bootstrap the loader at startup. - Extracted Unreal Engine 4/5 support into a standalone `GMConverter.UnrealEngine` plugin assembly that references only `GMConverter.SDK` plus CUE4Parse and MessagePack. The plugin contributes the PSK importer (with optional scene-manifest support) and the UE4 and UE2 archive explorers via `IPluginContext`. Host bundling is opt-in: `GMConverter.CLI` and `GMConverter.UI` list it via `` items in their csprojs, which trigger a loose `ProjectReference` (for build order) and a `CopyBundledPlugins` target that stages the plugin's `bin/` into the host's `bin/plugins/unrealengine/`. The host's CUE4Parse and MessagePack package references are gone — those move with the plugin. +- Extracted Source Engine support into a standalone `GMConverter.SourceEngine` plugin assembly that references only `GMConverter.SDK` plus MdlCrowbar. The plugin contributes the MDL importer (reads Garry's Mod / Source MDL via MdlCrowbar) and the MDL exporter (writes the full SMD / QC / VTF / VMT compile workspace via studiomdl + VTFCmd) via `IPluginContext` using DI activation. `MDLExporter` declares the full ~12-option schema (Tools / Materials / Physics groups) via the new `ExporterOptionSchema` so future generic UI rendering can drive the panel. Host's `MdlCrowbar` package ref and the entire `Source/` directory are gone from Core — they live in the plugin. The CoACD native and its MSBuild download target also moved with the plugin since CoACD is Source-physics-specific. Source compile binaries (`studiomdl.exe`, `VTFCmd.exe`) still auto-download into `./tools/` on first run. - Added `ITextureFactory` to the SDK. Plugins construct `Texture` instances by name/stream/raw RGBA bytes without taking a dependency on the host's image library. The default implementation (`DefaultTextureFactory` in Core) wraps SixLabors.ImageSharp and is registered in the host's service provider. - Wired Microsoft.Extensions.DependencyInjection into the plugin system. `IPluginContext` now exposes an `IServiceProvider Services` populated by the host with `ITextureFactory`, `ILoggerFactory`, and any future host-side services. Plugins consume these via constructor injection — `context.RegisterImporter()` activates `PSKImporter` against the provider, resolving its ctor params automatically. Named context properties for individual services (e.g. `TextureFactory`, `LoggerFactory`) were dropped in favor of a single `Services` source of truth; adding a new host capability is now a one-line registration in `PluginLoader` with zero impact on existing plugins. - Moved the format-agnostic helpers `NameHelpers`, `PathHelpers`, `PerfTimer`, and `ExplorerFileSystem` from `GMConverter.Common`/`GMConverter.Explorer` (Core) to `GMConverter.SDK.Common`/`GMConverter.SDK.Explorer` so plugins (and any future engine plugin) can share them without taking a Core reference. @@ -16,7 +17,9 @@ All notable changes to this project will be documented in this file. - Extracted the plugin-facing contract into a new `GMConverter.SDK` project under the `GMConverter.SDK.*` namespace, split by domain: `GMConverter.SDK.Geometry` (mesh/skeleton bits), `GMConverter.SDK.Animation` (clips and tracks), `GMConverter.SDK.Materials`, `GMConverter.SDK.Textures`, plus `GMConverter.SDK.Importers` / `Exporters` / `Explorer` / `Common`. The core `GMConverter` library now references the SDK and all moved types are `public` so engine plugins can compile against the contract without a dependency on the core library. CLI and UI behavior is unchanged. - Kept the SDK free of implementation dependencies. `Texture` is now an abstract base class with no image-library dependency; the concrete `ImageSharpTexture` implementation lives in the core library, and pipeline-specific transforms (`ToGltfMetallicRoughness`, `ToSourcePhongExponent`, `WithOpenGlNormalMap`, etc.) are exposed as extension methods on the SDK `Texture` that delegate to the concrete type. The MessagePack attribute on `ExplorerFileEntry` was removed; the UE4 scan cache continues to serialize it via the contractless resolver. -- Split `IExporter` to inherit from a new non-generic `IExporterDescriptor` so the plugin registry can hold exporters in a homogeneous collection and look them up by `OutputFormat` without committing to a specific options type at the call site. +- Split `IExporter` to inherit from a new non-generic `IExporterDescriptor` so the plugin registry can hold exporters in a homogeneous collection and look them up by `OutputFormat` without committing to a specific options type at the call site. *(Superseded later in the same `[Unreleased]` window — see the schema-driven `IExporter` entry below.)* +- Replaced `IExporter` (and the transitional `IExporterDescriptor`) with a single non-generic `IExporter` that exposes an `ExporterOptionSchema` and accepts an `ExportOptions` bag at invocation time. The SDK adds the schema types (`OptionType`, `OptionDescriptor`, `OptionGroup`, `ExporterOptionSchema`) and the bag value type (`ExportOptions`). Each exporter declares the options it accepts as data; the host builds an `ExportOptions` from UI/CLI inputs keyed by `OptionDescriptor.Key` and hands it to the plugin, which reads what it needs via typed accessors and constructs its own internal options record. Source Engine declares the full schema; GLTF and OBJ declare minimal schemas (or empty for OBJ). The generic schema-driven UI panel that consumes this is a follow-up — the existing `SourceEngineSettings.axaml` panel keeps rendering Source's options with its bespoke layout for now, just building the `ExportOptions` bag from its bindings. +- Removed UI-side CoACD physics preview generation. The Source plugin owns CoACD now (`CoacdNative` moved with it), and the UI no longer has direct access to the native; physics preview falls back to a bounds visualisation for both `bounds` and `coacd` modes. The actual export still produces CoACD physics when the user selects that mode. A follow-up could surface a plugin-contributed "preview physics" hook so the UI can render the real shape pre-export. - Hardened the plugin native-library loader. `PluginLoadContext.LoadUnmanagedDll` now falls back to a sibling-folder probe (with platform-conventional shared-library names) when `AssemblyDependencyResolver` returns no path. CUE4Parse-Natives ships as a `Content`/`CopyToOutputDirectory` item rather than a `runtimes//native/` layout and is not listed in deps.json as a native asset, so the resolver alone would not find it; the fallback closes that gap. ## [1.7.0] - 2026-05-21 diff --git a/GMConverter.CLI/GMConverter.CLI.csproj b/GMConverter.CLI/GMConverter.CLI.csproj index f9afd33..88565e2 100644 --- a/GMConverter.CLI/GMConverter.CLI.csproj +++ b/GMConverter.CLI/GMConverter.CLI.csproj @@ -23,6 +23,9 @@ plugin moves to its own repo, drop the entry here and ship the plugin through a release artifact instead. --> + + sourceengine + unrealengine diff --git a/GMConverter.CLI/Program.cs b/GMConverter.CLI/Program.cs index 1ba628b..d43cda3 100644 --- a/GMConverter.CLI/Program.cs +++ b/GMConverter.CLI/Program.cs @@ -4,9 +4,9 @@ using GMConverter.Importers; using GMConverter.Plugins; using GMConverter.SDK.Common; +using GMConverter.SDK.Exporters; using GMConverter.SDK.Geometry; using GMConverter.SDK.Importers; -using GMConverter.Source; using Microsoft.Extensions.Logging; namespace GMConverter.CLI; @@ -242,7 +242,12 @@ private static int Run( studioMdlPath, vtfCmdPath, buildMaterials, - CreatePhysicsOptions(generatePhysics, physicsModeText, physicsMass, coacdThreshold, maxConvexPieces, maxHullVertices)); + generatePhysics, + physicsModeText, + physicsMass, + coacdThreshold, + maxConvexPieces, + maxHullVertices); return 0; default: @@ -255,7 +260,7 @@ private static void RunObj(Model model, string outputDirectory, string baseName) Directory.CreateDirectory(outputDirectory); Console.WriteLine($"Writing OBJ output to {outputDirectory}"); - new OBJExporter().Export(model, outputDirectory, baseName, new OBJExportOptions()); + new OBJExporter().Export(model, outputDirectory, baseName, ExportOptions.Empty); } private static void RunGltf(Model model, string outputDirectory, string baseName, bool binary) @@ -263,7 +268,11 @@ private static void RunGltf(Model model, string outputDirectory, string baseName Directory.CreateDirectory(outputDirectory); Console.WriteLine($"Writing {(binary ? "GLB" : "glTF")} output to {outputDirectory}"); - new GLTFExporter().Export(model, outputDirectory, baseName, new GLTFExportOptions(binary)); + var options = new ExportOptions(new Dictionary + { + ["binary"] = binary, + }); + new GLTFExporter().Export(model, outputDirectory, baseName, options); } private static void RunMdl( @@ -274,16 +283,40 @@ private static void RunMdl( string? studioMdlPath, string? vtfCmdPath, bool buildMaterials, - PhysicsOptions? physicsOptions) + bool generatePhysics, + string? physicsModeText, + float physicsMass, + float coacdThreshold, + int maxConvexPieces, + int maxHullVertices) { Directory.CreateDirectory(outputDirectory); + var exporter = PluginHost.Registry.GetExporter("mdl") + ?? throw new GMConverterException("Source plugin not loaded: cannot produce MDL output. Install GMConverter.SourceEngine."); + Console.WriteLine($"Writing Source compile workspace to {outputDirectory}"); - new MDLExporter().Export( - model, - outputDirectory, - baseName, - new MDLExportOptions(modelPath, studioMdlPath, vtfCmdPath, buildMaterials, physicsOptions)); + var bag = new Dictionary + { + ["modelPath"] = modelPath, + ["studioMdlPath"] = studioMdlPath, + ["vtfCmdPath"] = vtfCmdPath, + ["buildMaterials"] = buildMaterials, + }; + if (generatePhysics || !string.IsNullOrWhiteSpace(physicsModeText)) + { + var mode = NormalizePhysicsMode(physicsModeText); + bag["physics:enabled"] = true; + bag["physics:mode"] = mode; + bag["physics:mass"] = physicsMass; + if (mode == "coacd") + { + bag["physics:coacdThreshold"] = coacdThreshold; + bag["physics:maxConvexPieces"] = maxConvexPieces; + bag["physics:maxHullVertices"] = maxHullVertices; + } + } + exporter.Export(model, outputDirectory, baseName, new ExportOptions(bag)); } private static IImporter GetImporter(string inputFormat, ILoggerFactory? loggerFactory = null) @@ -291,10 +324,9 @@ private static IImporter GetImporter(string inputFormat, ILoggerFactory? loggerF return inputFormat switch { "opt" => new OPTImporter(), - "mdl" => new MDLImporter(), "mow" => new MOWImporter(loggerFactory), _ => PluginHost.Registry.GetImporter(inputFormat) - ?? throw new ArgumentException($"Option --input-format '{inputFormat}' is not recognized. Built-ins: opt, mdl, mow. Plugins (e.g. GMConverter.UnrealEngine for psk) may contribute additional formats.") + ?? throw new ArgumentException($"Option --input-format '{inputFormat}' is not recognized. Built-ins: opt, mow. Plugins (GMConverter.UnrealEngine for psk; GMConverter.SourceEngine for mdl) may contribute additional formats.") }; } @@ -362,49 +394,6 @@ private static string SanitizePathToken(string value) return string.Concat(value.Select(c => char.IsLetterOrDigit(c) || c is '_' or '-' ? char.ToLowerInvariant(c) : '_')).Trim('_'); } - private static PhysicsOptions? CreatePhysicsOptions( - bool generatePhysics, - string? physicsModeText, - float mass, - float threshold, - int maxConvexPieces, - int maxHullVertices) - { - if (!generatePhysics && string.IsNullOrWhiteSpace(physicsModeText)) - { - return null; - } - - var mode = NormalizePhysicsMode(physicsModeText); - - if (mass <= 0) - { - throw new ArgumentException("Option --physics-mass must be greater than zero."); - } - - if (mode is PhysicsMode.Bounds) - { - return new PhysicsOptions(mode, mass, null); - } - - if (threshold is < 0.01f or > 1.0f) - { - throw new ArgumentException("Option --coacd-threshold must be between 0.01 and 1."); - } - - if (maxConvexPieces is 0 or < -1) - { - throw new ArgumentException("Option --max-convex-pieces must be -1 or greater than zero."); - } - - if (maxHullVertices < 4) - { - throw new ArgumentException("Option --coacd-max-hull-vertices must be at least 4."); - } - - return new PhysicsOptions(mode, mass, new CoacdOptions(threshold, maxConvexPieces, maxHullVertices)); - } - private static MaterialResolveOptions? CreateMaterialResolveOptions(string? materialDirectory) { if (string.IsNullOrWhiteSpace(materialDirectory)) @@ -442,12 +431,12 @@ private static string SanitizePathToken(string value) return fullPath; } - private static PhysicsMode NormalizePhysicsMode(string? physicsModeText) + private static string NormalizePhysicsMode(string? physicsModeText) { return physicsModeText?.Trim().ToLowerInvariant() switch { - null or "" or "bounds" => PhysicsMode.Bounds, - "coacd" => PhysicsMode.Coacd, + null or "" or "bounds" => "bounds", + "coacd" => "coacd", _ => throw new ArgumentException("Option --physics-mode must be 'bounds' or 'coacd'.") }; } diff --git a/GMConverter.SDK/Exporters/ExportOptions.cs b/GMConverter.SDK/Exporters/ExportOptions.cs new file mode 100644 index 0000000..330d6fe --- /dev/null +++ b/GMConverter.SDK/Exporters/ExportOptions.cs @@ -0,0 +1,107 @@ +using System.Globalization; + +namespace GMConverter.SDK.Exporters; + +/// +/// Opaque-but-typed-accessor bag of option values handed to an exporter at invocation time. +/// The host builds it from UI / CLI inputs (parsed values keyed by ); +/// the exporter reads what it needs via the typed Get helpers and constructs its own internal +/// strongly-typed options record from the result. This keeps the SDK contract opaque while +/// letting plugin code stay type-safe internally. +/// +public sealed class ExportOptions +{ + /// An empty bag — no keys present. Useful for default invocations. + public static ExportOptions Empty { get; } = new(new Dictionary()); + + private readonly IReadOnlyDictionary _values; + + public ExportOptions(IReadOnlyDictionary values) + { + ArgumentNullException.ThrowIfNull(values); + _values = values; + } + + /// True if the bag contains a value (possibly null) for the given key. + public bool Contains(string key) + { + return _values.ContainsKey(key); + } + + /// + /// Returns the value as a string, or null if not present or set to null. + /// Non-string values are converted via ToString() so callers can read primitive values + /// the host stored as their native CLR type (e.g. an int arrived from System.CommandLine). + /// + public string? GetString(string key) + { + if (!_values.TryGetValue(key, out var value) || value is null) + { + return null; + } + return value as string ?? value.ToString(); + } + + public bool GetBool(string key, bool defaultValue = false) + { + if (!_values.TryGetValue(key, out var value)) + { + return defaultValue; + } + return value switch + { + bool b => b, + string s when bool.TryParse(s, out var parsed) => parsed, + _ => defaultValue, + }; + } + + public int GetInt(string key, int defaultValue = 0) + { + if (!_values.TryGetValue(key, out var value)) + { + return defaultValue; + } + return value switch + { + int i => i, + long l => checked((int)l), + short s => s, + string s when int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) => parsed, + _ => defaultValue, + }; + } + + public float GetFloat(string key, float defaultValue = 0f) + { + if (!_values.TryGetValue(key, out var value)) + { + return defaultValue; + } + return value switch + { + float f => f, + double d => (float)d, + int i => i, + long l => l, + string s when float.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed) => parsed, + _ => defaultValue, + }; + } + + /// + /// Reference-type accessor for non-primitive values the host may have stored directly (rare — + /// the schema is designed around primitives, but this escape hatch exists for plugins that + /// want to round-trip complex objects across an invocation). + /// + public T? Get(string key) where T : class + { + return _values.TryGetValue(key, out var value) ? value as T : null; + } + + /// The raw underlying dictionary. Useful for diagnostics and host-side iteration. + public IReadOnlyDictionary AsDictionary() + { + return _values; + } +} diff --git a/GMConverter.SDK/Exporters/ExporterOptionSchema.cs b/GMConverter.SDK/Exporters/ExporterOptionSchema.cs new file mode 100644 index 0000000..ace53d9 --- /dev/null +++ b/GMConverter.SDK/Exporters/ExporterOptionSchema.cs @@ -0,0 +1,22 @@ +namespace GMConverter.SDK.Exporters; + +/// +/// The full set of options an exporter accepts, grouped for UI presentation. The host renders +/// one tab/section per group and one control per ; the CLI +/// registers one argument per descriptor. The schema is the single source of truth for option +/// keys, labels, types, defaults, and (for ) choices. +/// +public sealed record ExporterOptionSchema(IReadOnlyList Groups) +{ + /// An empty schema (no groups, no options). Returned by exporters with no settings. + public static ExporterOptionSchema Empty { get; } = new([]); + + /// Flattened view of every option in the schema, regardless of group. + public IEnumerable AllOptions => Groups.SelectMany(g => g.Options); + + /// Looks up an option by key, returning null if the key is not in the schema. + public OptionDescriptor? Find(string key) + { + return AllOptions.FirstOrDefault(o => string.Equals(o.Key, key, StringComparison.Ordinal)); + } +} diff --git a/GMConverter.SDK/Exporters/IExporter.cs b/GMConverter.SDK/Exporters/IExporter.cs index 1337263..1dd3d0b 100644 --- a/GMConverter.SDK/Exporters/IExporter.cs +++ b/GMConverter.SDK/Exporters/IExporter.cs @@ -3,10 +3,27 @@ namespace GMConverter.SDK.Exporters; /// -/// Exports a to a target format. +/// Exports a to a target format. Each exporter declares an +/// describing the configurable options it accepts; the host uses the +/// schema to render UI controls and CLI arguments, then constructs an +/// bag from user input and hands it to . Plugins read what they need from the +/// bag via the typed accessors and assemble their own internal strongly-typed options if desired. /// -/// -public interface IExporter : IExporterDescriptor +public interface IExporter { - void Export(Model model, string outputDirectory, string baseName, TOptions options); + /// Stable identifier of the output format (e.g. "obj", "glb", "mdl"). + string OutputFormat { get; } + + /// Human-readable name shown in UI dropdowns and CLI help. + string OutputName { get; } + + /// + /// Describes the options this exporter accepts. The host renders UI/CLI from this schema and + /// builds the passed to . Return + /// for exporters with no user-configurable options. + /// + ExporterOptionSchema OptionSchema { get; } + + /// Performs the export. is built by the host from the schema. + void Export(Model model, string outputDirectory, string baseName, ExportOptions options); } diff --git a/GMConverter.SDK/Exporters/IExporterDescriptor.cs b/GMConverter.SDK/Exporters/IExporterDescriptor.cs deleted file mode 100644 index 96def5d..0000000 --- a/GMConverter.SDK/Exporters/IExporterDescriptor.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace GMConverter.SDK.Exporters; - -/// -/// Non-generic metadata for an exporter. Lets the host store exporters in homogeneous -/// collections (plugin registries, dropdowns) without committing to a specific options type. -/// The actual export call lives on . -/// -public interface IExporterDescriptor -{ - string OutputFormat { get; } - - string OutputName { get; } -} diff --git a/GMConverter.SDK/Exporters/OptionDescriptor.cs b/GMConverter.SDK/Exporters/OptionDescriptor.cs new file mode 100644 index 0000000..e44b55c --- /dev/null +++ b/GMConverter.SDK/Exporters/OptionDescriptor.cs @@ -0,0 +1,48 @@ +namespace GMConverter.SDK.Exporters; + +/// +/// Describes a single configurable option an exporter accepts. The host uses this to render UI +/// controls, register CLI arguments, persist values, and validate before invoking Export. +/// +/// +/// Stable identifier used to look the value up in . Convention is to +/// prefix related options with a group-like tag (e.g. "physics:mode") when the option +/// logically belongs to a sub-feature, but the schema's structure is +/// the source of truth for UI grouping. +/// +/// Logical type used for rendering and parsing. +/// Human-readable label shown in UI. +public sealed record OptionDescriptor(string Key, OptionType Type, string Label) +{ + /// + /// Default value used when the host has nothing persisted for this key. May be null + /// (especially for and where + /// "unset" is a valid state). + /// + public object? DefaultValue { get; init; } + + /// + /// Optional deferred default — invoked lazily by . Use this when + /// the default needs to be computed at runtime (e.g. discovering an installed tool path on + /// the user's machine) rather than being known statically. + /// + public Func? DefaultValueFactory { get; init; } + + /// Optional human-readable help text shown alongside the control. + public string? Description { get; init; } + + /// + /// For : the allowed values. The host's enum control (combo box, + /// CLI choice constraint) restricts selection to this set. Required when Type is Enum. + /// + public IReadOnlyList? Choices { get; init; } + + /// + /// Resolves the effective default value at the time of the call. Calls + /// if set, otherwise returns . + /// + public object? ResolveDefault() + { + return DefaultValueFactory is not null ? DefaultValueFactory() : DefaultValue; + } +} diff --git a/GMConverter.SDK/Exporters/OptionGroup.cs b/GMConverter.SDK/Exporters/OptionGroup.cs new file mode 100644 index 0000000..6ab9d3b --- /dev/null +++ b/GMConverter.SDK/Exporters/OptionGroup.cs @@ -0,0 +1,13 @@ +namespace GMConverter.SDK.Exporters; + +/// +/// A logical grouping of related options — rendered as a tab, expander, or labelled section +/// depending on host. Keys must be unique within an exporter's schema. +/// +/// Stable group identifier (e.g. "tools", "physics"). +/// Human-readable header shown in the UI. +/// Options that belong to this group. +public sealed record OptionGroup( + string Key, + string Label, + IReadOnlyList Options); diff --git a/GMConverter.SDK/Exporters/OptionType.cs b/GMConverter.SDK/Exporters/OptionType.cs new file mode 100644 index 0000000..fdd2508 --- /dev/null +++ b/GMConverter.SDK/Exporters/OptionType.cs @@ -0,0 +1,35 @@ +using System.Diagnostics.CodeAnalysis; + +namespace GMConverter.SDK.Exporters; + +/// +/// Logical type of an exporter option, used by the host to render an appropriate control in the +/// UI and bind an appropriate CLI argument shape. Plugin authors typically discover these via +/// IntelliSense, so the names are chosen for instinctive matching against CLR primitive type +/// names (the same convention uses) — CA1720 is suppressed for this reason. +/// +[SuppressMessage("Naming", "CA1720:Identifier contains type name", + Justification = "Plugin authors instinctively type OptionType.String / Int / Bool / Float; matches the convention of System.TypeCode.")] +public enum OptionType +{ + /// Free-form text. + String, + + /// Filesystem path; UI renders a path picker. + Path, + + /// Boolean toggle. + Bool, + + /// Whole number. + Int, + + /// Floating-point number. + Float, + + /// + /// One of a fixed set of string values. The property + /// must be populated when this type is used. + /// + Enum, +} diff --git a/GMConverter.SDK/Plugins/IPluginContext.cs b/GMConverter.SDK/Plugins/IPluginContext.cs index 8cbc65e..80d1119 100644 --- a/GMConverter.SDK/Plugins/IPluginContext.cs +++ b/GMConverter.SDK/Plugins/IPluginContext.cs @@ -41,13 +41,13 @@ public interface IPluginContext /// /// Registers an exporter instance with the host. /// - void RegisterExporter(IExporterDescriptor exporter); + void RegisterExporter(IExporter exporter); /// /// Activates via ActivatorUtilities.CreateInstance against /// and registers the resulting exporter. /// - void RegisterExporter() where T : class, IExporterDescriptor; + void RegisterExporter() where T : class, IExporter; /// /// Registers an archive explorer (browser) instance with the host. diff --git a/GMConverter.SDK/Textures/Texture.cs b/GMConverter.SDK/Textures/Texture.cs index c64ba44..ff3e743 100644 --- a/GMConverter.SDK/Textures/Texture.cs +++ b/GMConverter.SDK/Textures/Texture.cs @@ -19,6 +19,15 @@ public abstract class Texture : IDisposable public abstract string DebugDimensions { get; } + /// + /// Returns the texture's pixel data as RGBA8888, row-major order, length + /// Width * Height * 4. Used by plugins that need to apply custom pixel transforms + /// (channel swaps, masking, format-specific encodings) without taking a dependency on the + /// host's image library. The returned buffer is a copy; callers are free to mutate it + /// (typically just to read). + /// + public abstract byte[] GetRgbaPixels(); + /// /// Returns a copy of this texture scaled so its longest edge is at most , /// preserving aspect ratio. Returns the same instance when already within the cap or when diff --git a/GMConverter/Common/ProcessRunner.cs b/GMConverter.SourceEngine/Common/ProcessRunner.cs similarity index 96% rename from GMConverter/Common/ProcessRunner.cs rename to GMConverter.SourceEngine/Common/ProcessRunner.cs index 823f72a..aaa1305 100644 --- a/GMConverter/Common/ProcessRunner.cs +++ b/GMConverter.SourceEngine/Common/ProcessRunner.cs @@ -2,7 +2,7 @@ using GMConverter.SDK.Common; -namespace GMConverter.Common; +namespace GMConverter.SourceEngine.Common; internal static class ProcessRunner { diff --git a/GMConverter/Exporters/MDLExporter.cs b/GMConverter.SourceEngine/Exporters/MDLExporter.cs similarity index 84% rename from GMConverter/Exporters/MDLExporter.cs rename to GMConverter.SourceEngine/Exporters/MDLExporter.cs index df2bcb3..5abda9e 100644 --- a/GMConverter/Exporters/MDLExporter.cs +++ b/GMConverter.SourceEngine/Exporters/MDLExporter.cs @@ -1,25 +1,31 @@ using System.Numerics; using System.Text; -using GMConverter.Common; -using GMConverter.Geometry; using GMConverter.SDK.Animation; using GMConverter.SDK.Common; using GMConverter.SDK.Exporters; using GMConverter.SDK.Geometry; using GMConverter.SDK.Materials; using GMConverter.SDK.Textures; -using GMConverter.Source; +using GMConverter.SourceEngine.Common; +using GMConverter.SourceEngine.Geometry; -namespace GMConverter.Exporters; +namespace GMConverter.SourceEngine.Exporters; /// /// Exports a model to the Source Engine MDL format. /// -internal sealed class MDLExporter : IExporter +internal sealed class MDLExporter : IExporter { private static readonly UTF8Encoding _utf8NoBom = new(false); private const int _sourceMaxConvexPieces = 1024; + private readonly ITextureFactory _textureFactory; + + public MDLExporter(ITextureFactory textureFactory) + { + _textureFactory = textureFactory; + } + // Source's "1 unit" = 1 inch. Our importer pipeline produces models in meters (Unreal cm is // scaled by 0.01 in PSKImporter.ParseScene). Multiply by 39.3700787 (in/m) when writing SMD // so the exported MDL renders at its real-world size in-engine. @@ -29,12 +35,84 @@ internal sealed class MDLExporter : IExporter public string OutputName => "Source Engine"; + // Full schema declaration — three groups (Tools, Materials, Physics). The host's generic + // options panel renders these directly; CLI registers a --mdl- argument per descriptor. + public ExporterOptionSchema OptionSchema { get; } = new( + [ + new OptionGroup("tools", "Tools", + [ + new OptionDescriptor("modelPath", OptionType.String, "Model path") + { + Description = "Output MDL path under the game models directory. Defaults to gmconverter/.mdl.", + }, + new OptionDescriptor("studioMdlPath", OptionType.Path, "StudioMDL override") + { + Description = "Optional StudioMDL-CE executable. Auto-downloads to tools/ when omitted.", + }, + new OptionDescriptor("vtfCmdPath", OptionType.Path, "VTFCmd override") + { + Description = "Optional VTFCmd executable. Auto-downloads VTFEdit Reloaded to tools/ when omitted and materials are built.", + }, + new OptionDescriptor("buildMaterials", OptionType.Bool, "Build materials") + { + DefaultValue = true, + Description = "Compile VTFs and VMTs alongside the MDL. Disable for mesh-only output.", + }, + ]), + new OptionGroup("material", "Materials", + [ + new OptionDescriptor("material:maxTextureSize", OptionType.Enum, "Max texture size") + { + DefaultValue = "0", + Choices = ["0", "512", "1024", "2048", "4096"], + Description = "Cap the longest edge before VTF compile. 0 disables resizing.", + }, + new OptionDescriptor("material:deduplicateTextures", OptionType.Bool, "Deduplicate identical textures") + { + DefaultValue = false, + Description = "Hash resized texture content and reuse an existing VTF when materials produce byte-identical maps.", + }, + ]), + new OptionGroup("physics", "Physics", + [ + new OptionDescriptor("physics:enabled", OptionType.Bool, "Generate physics") + { + DefaultValue = false, + Description = "Generate a simple collision mesh alongside the MDL.", + }, + new OptionDescriptor("physics:mode", OptionType.Enum, "Physics mode") + { + DefaultValue = "bounds", + Choices = ["bounds", "coacd"], + Description = "bounds: single AABB hull. coacd: native convex decomposition.", + }, + new OptionDescriptor("physics:mass", OptionType.Float, "Mass (kg)") + { + DefaultValue = 100f, + }, + new OptionDescriptor("physics:coacdThreshold", OptionType.Float, "CoACD threshold") + { + DefaultValue = 0.05f, + Description = "CoACD termination threshold from 0.01 to 1.", + }, + new OptionDescriptor("physics:maxConvexPieces", OptionType.Int, "Max convex pieces") + { + DefaultValue = 32, + }, + new OptionDescriptor("physics:maxHullVertices", OptionType.Int, "Max hull vertices") + { + DefaultValue = 32, + }, + ]), + ]); + public void Export( Model model, string outputDirectory, string baseName, - MDLExportOptions options) + ExportOptions exportOptions) { + var options = BuildOptions(baseName, exportOptions); var sourceTools = SourceToolPaths.Resolve(options.StudioMdlPath, options.VtfCmdPath, options.BuildMaterials); var physicsOptions = options.Physics; var modelPath = options.ModelPath; @@ -78,7 +156,46 @@ public void Export( Compile(model, result, sourceTools, options.BuildMaterials, options.MaterialOptimization); } - private static void Compile( + // Binds the host's option bag into the strongly-typed MDLExportOptions the rest of this + // file already knows how to work with. Keeps the option-bag boundary tight to this method. + private static MDLExportOptions BuildOptions(string baseName, ExportOptions o) + { + var modelPath = o.GetString("modelPath"); + if (string.IsNullOrWhiteSpace(modelPath)) + { + modelPath = $"gmconverter/{NameHelpers.SanitizeMaterialName(baseName)}.mdl"; + } + + var physics = o.GetBool("physics:enabled") + ? new PhysicsOptions( + Mode: o.GetString("physics:mode") switch + { + "coacd" => PhysicsMode.Coacd, + _ => PhysicsMode.Bounds, + }, + Mass: o.GetFloat("physics:mass", 100f), + Coacd: o.GetString("physics:mode") == "coacd" + ? new CoacdOptions( + Threshold: o.GetFloat("physics:coacdThreshold", 0.05f), + MaxConvexPieces: o.GetInt("physics:maxConvexPieces", 32), + MaxHullVertices: o.GetInt("physics:maxHullVertices", 32)) + : null) + : null; + + var material = new MaterialOptimizationOptions( + MaxTextureSize: o.GetInt("material:maxTextureSize"), + DeduplicateTextures: o.GetBool("material:deduplicateTextures")); + + return new MDLExportOptions( + ModelPath: modelPath, + StudioMdlPath: o.GetString("studioMdlPath"), + VtfCmdPath: o.GetString("vtfCmdPath"), + BuildMaterials: o.GetBool("buildMaterials", defaultValue: true), + Physics: physics, + MaterialOptimization: material); + } + + private void Compile( Model model, MDLExportResult result, SourceToolPaths sourceTools, @@ -91,6 +208,7 @@ private static void Compile( { var materialCompiler = new SourceMaterialCompiler( sourceTools.VtfCmdPath!, + _textureFactory, materialOptimization ?? MaterialOptimizationOptions.Default); materialCompiler.Compile(model.Materials, result.MaterialDirectory, result.MaterialRelativeDirectory); } @@ -605,7 +723,7 @@ private static string EscapeQcString(string value) return value.Replace("\"", "'", StringComparison.Ordinal); } - private static void ExportSourceMaterials(Model model, string materialDirectory, string materialRelativeDirectory) + private void ExportSourceMaterials(Model model, string materialDirectory, string materialRelativeDirectory) { foreach (var material in model.Materials) { @@ -630,7 +748,7 @@ private static void ExportSourceMaterials(Model model, string materialDirectory, // for envmap masking on bump-mapped materials. var specForMask = GetSourcePhongExponent(material); var normalTextureForWrite = material.NormalTexture is not null && specForMask is not null - ? material.NormalTexture.WithMaskInAlpha(specForMask) + ? material.NormalTexture.WithMaskInAlpha(specForMask, _textureFactory) : material.NormalTexture; normalTextureForWrite?.WritePng(Path.Combine(materialDirectory, $"{material.Name}_normal.png")); specForMask?.WritePng(Path.Combine(materialDirectory, $"{material.Name}_spec.png")); @@ -694,10 +812,10 @@ private static bool UseSourcePhong(Material material) return material.DiffuseTexture is not null && material.SpecularTexture is not null; } - private static Texture? GetSourcePhongExponent(Material material) + private Texture? GetSourcePhongExponent(Material material) { return material.SpecularTexturePacking == MaterialSpecularTexturePacking.UnrealSpecularMasks - ? material.SpecularTexture?.ToSourcePhongExponent() + ? material.SpecularTexture?.ToSourcePhongExponent(_textureFactory) : material.SpecularTexture; } diff --git a/GMConverter.SourceEngine/GMConverter.SourceEngine.csproj b/GMConverter.SourceEngine/GMConverter.SourceEngine.csproj new file mode 100644 index 0000000..1a7816d --- /dev/null +++ b/GMConverter.SourceEngine/GMConverter.SourceEngine.csproj @@ -0,0 +1,92 @@ + + + + net10.0 + GMConverter.SourceEngine + GMConverter.SourceEngine + GMConverter.SourceEngine + enable + enable + true + + true + + + 1.0.11 + true + + win_amd64 + manylinux_2_17_x86_64.manylinux2014_x86_64 + manylinux_2_17_aarch64.manylinux2014_aarch64 + macosx_11_0_arm64 + + lib_coacd.dll + lib_coacd.so + lib_coacd.so + lib_coacd.dylib + + 4de22f70d1a3fa8c44698c8006a223fe5fb0ee84b76adecf3726cf2003e9145f + a60f700a52e5b40462e508c14bb756cd63ce7e6a95ff72ae0b1592be1dbb0106 + e376fadb22790444c7253f0cee9104a1af01ec965488c1318e84e3b2dbf1e2a3 + adbc58259a721cc5ede24cc8c2671a95e75a8a52dc1ee4d953d80d236b192da9 + + coacd-$(CoacdVersion)-cp39-abi3-$(CoacdPlatformTag).whl + https://github.com/SarahWeiii/CoACD/releases/download/$(CoacdVersion)/$(CoacdWheelFileName) + $(MSBuildProjectExtensionsPath)coacd\$(CoacdVersion)\ + $(CoacdCacheDirectory)extracted\ + $(CoacdCacheDirectory)$(CoacdWheelFileName) + $(CoacdExtractDirectory)coacd\$(CoacdNativeLibraryName) + + + + + + + + + + + + + + + + + + + + $(CoacdNativeLibraryName) + $(CoacdNativeLibraryName) + PreserveNewest + PreserveNewest + false + + + + + + + + + + + + + + + + + + + + + + + diff --git a/GMConverter/Source/GameInfo.cs b/GMConverter.SourceEngine/GameInfo.cs similarity index 99% rename from GMConverter/Source/GameInfo.cs rename to GMConverter.SourceEngine/GameInfo.cs index f089784..d2c1800 100644 --- a/GMConverter/Source/GameInfo.cs +++ b/GMConverter.SourceEngine/GameInfo.cs @@ -1,6 +1,6 @@ using GMConverter.SDK.Common; -namespace GMConverter.Source; +namespace GMConverter.SourceEngine; internal sealed record GameInfo( string GameDirectory, diff --git a/GMConverter/Geometry/CoacdDecompositionOptions.cs b/GMConverter.SourceEngine/Geometry/CoacdDecompositionOptions.cs similarity index 73% rename from GMConverter/Geometry/CoacdDecompositionOptions.cs rename to GMConverter.SourceEngine/Geometry/CoacdDecompositionOptions.cs index 4c2277a..3088bc5 100644 --- a/GMConverter/Geometry/CoacdDecompositionOptions.cs +++ b/GMConverter.SourceEngine/Geometry/CoacdDecompositionOptions.cs @@ -1,4 +1,4 @@ -namespace GMConverter.Geometry; +namespace GMConverter.SourceEngine.Geometry; internal sealed record CoacdDecompositionOptions( double Threshold, diff --git a/GMConverter/Geometry/CoacdNative.cs b/GMConverter.SourceEngine/Geometry/CoacdNative.cs similarity index 99% rename from GMConverter/Geometry/CoacdNative.cs rename to GMConverter.SourceEngine/Geometry/CoacdNative.cs index 4590351..b2bacb1 100644 --- a/GMConverter/Geometry/CoacdNative.cs +++ b/GMConverter.SourceEngine/Geometry/CoacdNative.cs @@ -4,7 +4,7 @@ using GMConverter.SDK.Common; using GMConverter.SDK.Geometry; -namespace GMConverter.Geometry; +namespace GMConverter.SourceEngine.Geometry; internal static partial class CoacdNative { diff --git a/GMConverter/Importers/MDLImporter.cs b/GMConverter.SourceEngine/Importers/MDLImporter.cs similarity index 99% rename from GMConverter/Importers/MDLImporter.cs rename to GMConverter.SourceEngine/Importers/MDLImporter.cs index 20d013b..575ccdd 100644 --- a/GMConverter/Importers/MDLImporter.cs +++ b/GMConverter.SourceEngine/Importers/MDLImporter.cs @@ -9,7 +9,7 @@ using static MdlCrowbar.Enums; using Mesh = GMConverter.SDK.Geometry.Mesh; -namespace GMConverter.Importers; +namespace GMConverter.SourceEngine.Importers; internal sealed class MDLImporter : IImporter { diff --git a/GMConverter/Source/MaterialOptimizationOptions.cs b/GMConverter.SourceEngine/MaterialOptimizationOptions.cs similarity index 94% rename from GMConverter/Source/MaterialOptimizationOptions.cs rename to GMConverter.SourceEngine/MaterialOptimizationOptions.cs index acbac75..c8caf1e 100644 --- a/GMConverter/Source/MaterialOptimizationOptions.cs +++ b/GMConverter.SourceEngine/MaterialOptimizationOptions.cs @@ -1,4 +1,4 @@ -namespace GMConverter.Source; +namespace GMConverter.SourceEngine; /// /// Controls Source material compile texture optimizations. caps diff --git a/GMConverter/Source/PhysicsOptions.cs b/GMConverter.SourceEngine/PhysicsOptions.cs similarity index 87% rename from GMConverter/Source/PhysicsOptions.cs rename to GMConverter.SourceEngine/PhysicsOptions.cs index 10e649f..bc38cd7 100644 --- a/GMConverter/Source/PhysicsOptions.cs +++ b/GMConverter.SourceEngine/PhysicsOptions.cs @@ -1,4 +1,4 @@ -namespace GMConverter.Source; +namespace GMConverter.SourceEngine; internal enum PhysicsMode { diff --git a/GMConverter.SourceEngine/SourceEnginePlugin.cs b/GMConverter.SourceEngine/SourceEnginePlugin.cs new file mode 100644 index 0000000..d27980c --- /dev/null +++ b/GMConverter.SourceEngine/SourceEnginePlugin.cs @@ -0,0 +1,30 @@ +using GMConverter.SDK.Plugins; +using GMConverter.SourceEngine.Exporters; +using GMConverter.SourceEngine.Importers; + +[assembly: Plugin(typeof(GMConverter.SourceEngine.SourceEnginePlugin))] + +namespace GMConverter.SourceEngine; + +/// +/// Plugin entry point for Source Engine support. Registers the MDL importer (reads Garry's Mod / +/// Source MDL files via MdlCrowbar) and the MDL exporter (writes the full SMD / QC / VTF / VMT +/// compile workspace via studiomdl + VTFCmd). Both are activated via the host's DI container so +/// their ITextureFactory dependency is wired automatically. +/// +public sealed class SourceEnginePlugin : IPlugin +{ + public string Id => "gmconverter.sourceengine"; + + public string DisplayName => "Source Engine"; + + public void OnLoad(IPluginContext context) + { + context.RegisterImporter(); + context.RegisterExporter(); + } + + public void OnUnload() + { + } +} diff --git a/GMConverter/Source/SourceMaterialCompiler.cs b/GMConverter.SourceEngine/SourceMaterialCompiler.cs similarity index 96% rename from GMConverter/Source/SourceMaterialCompiler.cs rename to GMConverter.SourceEngine/SourceMaterialCompiler.cs index 11cee3f..71eed66 100644 --- a/GMConverter/Source/SourceMaterialCompiler.cs +++ b/GMConverter.SourceEngine/SourceMaterialCompiler.cs @@ -1,21 +1,22 @@ using System.Text; -using GMConverter.Common; -using GMConverter.Geometry; using GMConverter.SDK.Common; using GMConverter.SDK.Materials; using GMConverter.SDK.Textures; +using GMConverter.SourceEngine.Common; -namespace GMConverter.Source; +namespace GMConverter.SourceEngine; internal sealed class SourceMaterialCompiler { private readonly string _vtfCmdPath; private readonly MaterialOptimizationOptions _optimization; + private readonly ITextureFactory _textureFactory; private static readonly UTF8Encoding _utf8NoBom = new(false); - public SourceMaterialCompiler(string vtfCmdPath, MaterialOptimizationOptions? optimization = null) + public SourceMaterialCompiler(string vtfCmdPath, ITextureFactory textureFactory, MaterialOptimizationOptions? optimization = null) { _vtfCmdPath = Path.GetFullPath(vtfCmdPath); + _textureFactory = textureFactory; _optimization = optimization ?? MaterialOptimizationOptions.Default; } @@ -63,7 +64,7 @@ public void Compile(IEnumerable materials, string materialOutputDirect if (material.NormalTexture is not null) { var normalTextureForWrite = specForMask is not null - ? material.NormalTexture.WithMaskInAlpha(specForMask) + ? material.NormalTexture.WithMaskInAlpha(specForMask, _textureFactory) : material.NormalTexture; normalBasename = WriteOrReuse( normalTextureForWrite, @@ -230,10 +231,10 @@ private static bool UseSourcePhong(Material material) return material.DiffuseTexture is not null && material.SpecularTexture is not null; } - private static Texture? GetSourcePhongExponent(Material material) + private Texture? GetSourcePhongExponent(Material material) { return material.SpecularTexturePacking == MaterialSpecularTexturePacking.UnrealSpecularMasks - ? material.SpecularTexture?.ToSourcePhongExponent() + ? material.SpecularTexture?.ToSourcePhongExponent(_textureFactory) : material.SpecularTexture; } diff --git a/GMConverter/Source/SourceMaterialSurfaceProps.cs b/GMConverter.SourceEngine/SourceMaterialSurfaceProps.cs similarity index 91% rename from GMConverter/Source/SourceMaterialSurfaceProps.cs rename to GMConverter.SourceEngine/SourceMaterialSurfaceProps.cs index 7c76d0f..504940b 100644 --- a/GMConverter/Source/SourceMaterialSurfaceProps.cs +++ b/GMConverter.SourceEngine/SourceMaterialSurfaceProps.cs @@ -1,6 +1,6 @@ using GMConverter.SDK.Materials; -namespace GMConverter.Source; +namespace GMConverter.SourceEngine; internal static class SourceMaterialSurfaceProps { diff --git a/GMConverter/Source/SourcePhongSettings.cs b/GMConverter.SourceEngine/SourcePhongSettings.cs similarity index 92% rename from GMConverter/Source/SourcePhongSettings.cs rename to GMConverter.SourceEngine/SourcePhongSettings.cs index cfa6595..d993b4a 100644 --- a/GMConverter/Source/SourcePhongSettings.cs +++ b/GMConverter.SourceEngine/SourcePhongSettings.cs @@ -1,6 +1,6 @@ using GMConverter.SDK.Materials; -namespace GMConverter.Source; +namespace GMConverter.SourceEngine; internal readonly record struct SourcePhongSettings( string Boost, diff --git a/GMConverter.SourceEngine/SourceTextureTransforms.cs b/GMConverter.SourceEngine/SourceTextureTransforms.cs new file mode 100644 index 0000000..0e81e2c --- /dev/null +++ b/GMConverter.SourceEngine/SourceTextureTransforms.cs @@ -0,0 +1,93 @@ +using System.Runtime.InteropServices; +using GMConverter.SDK.Textures; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; + +namespace GMConverter.SourceEngine; + +/// +/// Source-Engine-specific texture transforms. These used to live in Core's +/// TextureExtensions and downcast to ImageSharpTexture; with Source extracted into +/// its own plugin assembly they had to be reimplemented against the SDK +/// surface ( + ). The +/// plugin still uses ImageSharp internally for the resize path when a mask's dimensions don't +/// match the target texture, but the result is handed back to the host via the factory. +/// +internal static class SourceTextureTransforms +{ + /// + /// Builds a Source $phongexponenttexture. Maps Fortnite SpecularMasks packing + /// (R=Specular, G=Metallic, B=Roughness) to Source's per-pixel exponent scale (RGB) and + /// phong mask in alpha. Inverts roughness so smooth surfaces produce sharp highlights. + /// + public static Texture ToSourcePhongExponent(this Texture texture, ITextureFactory factory, string? textureName = null) + { + var src = texture.GetRgbaPixels(); + var output = new byte[src.Length]; + for (var i = 0; i + 4 <= src.Length; i += 4) + { + var r = src[i]; + var b = src[i + 2]; + var exponent = (byte)(byte.MaxValue - b); + output[i] = exponent; + output[i + 1] = exponent; + output[i + 2] = exponent; + output[i + 3] = r; + } + return factory.FromRgba( + textureName ?? $"{texture.Name}_phong_exponent", + texture.Width, + texture.Height, + output); + } + + /// + /// Returns a copy of with its alpha channel replaced by the + /// mask's red channel. Used for Source's $normalmapalphaenvmapmask workflow, where + /// the envmap mask must live in the normal map's alpha rather than a separate texture + /// (Source rejects $envmapmask alongside $bumpmap due to pixel-shader register + /// limits in VertexLitGeneric). When the mask's dimensions differ from the texture's, the + /// mask is resized in-plugin via ImageSharp. + /// + public static Texture WithMaskInAlpha( + this Texture texture, + Texture mask, + ITextureFactory factory, + string? textureName = null) + { + var src = texture.GetRgbaPixels(); + var maskPixels = mask.Width == texture.Width && mask.Height == texture.Height + ? mask.GetRgbaPixels() + : ResizeRgba(mask.GetRgbaPixels(), mask.Width, mask.Height, texture.Width, texture.Height); + + var output = new byte[src.Length]; + for (var i = 0; i + 4 <= src.Length; i += 4) + { + output[i] = src[i]; + output[i + 1] = src[i + 1]; + output[i + 2] = src[i + 2]; + output[i + 3] = maskPixels[i]; + } + return factory.FromRgba( + textureName ?? $"{texture.Name}_with_mask", + texture.Width, + texture.Height, + output, + hasAlpha: true); + } + + private static byte[] ResizeRgba(byte[] sourceRgba, int sourceWidth, int sourceHeight, int targetWidth, int targetHeight) + { + // The plugin has SixLabors.ImageSharp available for its own internal use — we use it here + // for the resize because reimplementing a quality resampler against raw bytes is more + // complexity than the value of avoiding the dep. The result still flows back into the + // host's Texture via ITextureFactory.FromRgba so the host's image library remains + // pluggable from the SDK's point of view. + using var image = Image.LoadPixelData(sourceRgba, sourceWidth, sourceHeight); + image.Mutate(ctx => ctx.Resize(targetWidth, targetHeight)); + var output = new byte[targetWidth * targetHeight * 4]; + image.CopyPixelDataTo(MemoryMarshal.AsBytes(output.AsSpan())); + return output; + } +} diff --git a/GMConverter/Source/SourceToolPaths.cs b/GMConverter.SourceEngine/SourceToolPaths.cs similarity index 99% rename from GMConverter/Source/SourceToolPaths.cs rename to GMConverter.SourceEngine/SourceToolPaths.cs index 7b3a5b7..2906214 100644 --- a/GMConverter/Source/SourceToolPaths.cs +++ b/GMConverter.SourceEngine/SourceToolPaths.cs @@ -3,7 +3,7 @@ using System.Text.Json; using GMConverter.SDK.Common; -namespace GMConverter.Source; +namespace GMConverter.SourceEngine; internal sealed record SourceToolPaths( string StudioMdlPath, diff --git a/GMConverter.SourceEngine/plugin.json b/GMConverter.SourceEngine/plugin.json new file mode 100644 index 0000000..f932c09 --- /dev/null +++ b/GMConverter.SourceEngine/plugin.json @@ -0,0 +1,11 @@ +{ + "id": "gmconverter.sourceengine", + "version": "0.1.0", + "displayName": "Source Engine", + "entry": "GMConverter.SourceEngine.dll", + "sdkVersion": "1.0", + "capabilities": [ + "importer:mdl", + "exporter:mdl" + ] +} diff --git a/GMConverter.UI/GMConverter.UI.csproj b/GMConverter.UI/GMConverter.UI.csproj index a5dfb8b..723643a 100644 --- a/GMConverter.UI/GMConverter.UI.csproj +++ b/GMConverter.UI/GMConverter.UI.csproj @@ -36,6 +36,9 @@ + + sourceengine + unrealengine diff --git a/GMConverter.UI/Services/ConversionService.cs b/GMConverter.UI/Services/ConversionService.cs index 1bd81a1..d64199b 100644 --- a/GMConverter.UI/Services/ConversionService.cs +++ b/GMConverter.UI/Services/ConversionService.cs @@ -1,13 +1,12 @@ using System.Numerics; using GMConverter.Exporters; -using GMConverter.Geometry; using GMConverter.Importers; using GMConverter.Plugins; using GMConverter.SDK.Common; +using GMConverter.SDK.Exporters; using GMConverter.SDK.Geometry; using GMConverter.SDK.Importers; using GMConverter.SDK.Materials; -using GMConverter.Source; using Microsoft.Extensions.Logging; namespace GMConverter.UI.Services; @@ -63,7 +62,7 @@ public string RunConversion(ConversionSettings settings) Directory.CreateDirectory(outputPath); using (PerfTimer.Measure("convert.run", "OBJExporter.Export")) { - new OBJExporter().Export(model, outputPath, baseName, new OBJExportOptions()); + new OBJExporter().Export(model, outputPath, baseName, ExportOptions.Empty); } return $"Wrote OBJ output to {outputPath}"; @@ -72,11 +71,11 @@ public string RunConversion(ConversionSettings settings) Directory.CreateDirectory(outputPath); using (PerfTimer.Measure("convert.run", "GLTFExporter.Export", settings.OutputFormat)) { - new GLTFExporter().Export( - model, - outputPath, - baseName, - new GLTFExportOptions(settings.OutputFormat is "glb")); + var gltfOptions = new ExportOptions(new Dictionary + { + ["binary"] = settings.OutputFormat is "glb", + }); + new GLTFExporter().Export(model, outputPath, baseName, gltfOptions); } return $"Wrote {(settings.OutputFormat is "glb" ? "GLB" : "glTF")} output to {outputPath}"; @@ -85,17 +84,9 @@ public string RunConversion(ConversionSettings settings) Directory.CreateDirectory(outputPath); using (PerfTimer.Measure("convert.run", "MDLExporter.Export")) { - new MDLExporter().Export( - model, - outputPath, - baseName, - new MDLExportOptions( - settings.ModelPath ?? $"gmconverter/{SanitizePathToken(baseName)}.mdl", - settings.StudioMdlPath, - settings.VtfCmdPath, - settings.BuildMaterials, - CreatePhysicsOptions(settings), - new MaterialOptimizationOptions(settings.MaxTextureSize, settings.DeduplicateTextures))); + var mdlExporter = PluginHost.Registry.GetExporter("mdl") + ?? throw new GMConverterException("Source plugin not loaded: cannot produce MDL output. Install GMConverter.SourceEngine."); + mdlExporter.Export(model, outputPath, baseName, BuildMdlExportOptions(settings, baseName)); } return $"Wrote Source compile workspace to {outputPath}"; @@ -136,7 +127,12 @@ public PreviewLoadResult LoadPreview(ConversionSettings settings) // KHR_texture_transform; the SharpEngine glTF importer used by the in-app preview does // not honor that extension, so without inline baking multi-layer Fortnite materials // sample the wrong tile of their bake and render as garbled textures. - new GLTFExporter().Export(model, previewDirectory, baseName, new GLTFExportOptions(Binary: true, BakeUvTransforms: true)); + var previewOptions = new ExportOptions(new Dictionary + { + ["binary"] = true, + ["bakeUvTransforms"] = true, + }); + new GLTFExporter().Export(model, previewDirectory, baseName, previewOptions); } PhysicsPreviewExport physicsPreview; @@ -176,7 +172,6 @@ private static IImporter CreateImporter(string inputFormat, ILoggerFactory? logg return inputFormat switch { "opt" => new OPTImporter(), - "mdl" => new MDLImporter(), "mow" => new MOWImporter(loggerFactory), _ => PluginHost.Registry.GetImporter(inputFormat) ?? throw new GMConverterException($"Unsupported input format: {inputFormat}") @@ -233,32 +228,44 @@ private static string RequireOutputPath(string? path, string outputFormat) return new MaterialResolveOptions(fullPath); } - private static PhysicsOptions? CreatePhysicsOptions(ConversionSettings settings) + // Builds the host-side ExportOptions bag from ConversionSettings. Translates UI fields into + // the schema's option keys. Lives here (not on the exporter) because the host owns the + // mapping from its settings shape to the exporter's bag — the exporter doesn't see settings. + // After Source migration to a plugin the host no longer references plugin-private types + // (PhysicsOptions/PhysicsMode etc.), so the bag is built directly from primitives. + private static ExportOptions BuildMdlExportOptions(ConversionSettings settings, string baseName) { - if (!settings.GeneratePhysics && string.IsNullOrWhiteSpace(settings.PhysicsMode)) + var bag = new Dictionary { - return null; - } - - var mode = settings.PhysicsMode?.Trim().ToLowerInvariant() switch - { - null or "" or "bounds" => PhysicsMode.Bounds, - "coacd" => PhysicsMode.Coacd, - _ => throw new GMConverterException("Unsupported physics mode.") + ["modelPath"] = settings.ModelPath ?? $"gmconverter/{SanitizePathToken(baseName)}.mdl", + ["studioMdlPath"] = settings.StudioMdlPath, + ["vtfCmdPath"] = settings.VtfCmdPath, + ["buildMaterials"] = settings.BuildMaterials, + ["material:maxTextureSize"] = settings.MaxTextureSize, + ["material:deduplicateTextures"] = settings.DeduplicateTextures, }; - - if (mode is PhysicsMode.Bounds) + if (settings.GeneratePhysics || !string.IsNullOrWhiteSpace(settings.PhysicsMode)) { - return new PhysicsOptions(mode, settings.PhysicsMass, null); + var mode = settings.PhysicsMode?.Trim().ToLowerInvariant() switch + { + null or "" or "bounds" => "bounds", + "coacd" => "coacd", + _ => throw new GMConverterException("Unsupported physics mode."), + }; + bag["physics:enabled"] = true; + bag["physics:mode"] = mode; + bag["physics:mass"] = settings.PhysicsMass; + if (mode == "coacd") + { + bag["physics:coacdThreshold"] = settings.CoacdThreshold; + bag["physics:maxConvexPieces"] = settings.MaxConvexPieces; + bag["physics:maxHullVertices"] = settings.MaxHullVertices; + } } - - return new PhysicsOptions( - mode, - settings.PhysicsMass, - new CoacdOptions(settings.CoacdThreshold, settings.MaxConvexPieces, settings.MaxHullVertices)); + return new ExportOptions(bag); } - private PhysicsPreviewExport ExportPhysicsPreview(ConversionSettings settings, Model model, string previewDirectory, string baseName) + private static PhysicsPreviewExport ExportPhysicsPreview(ConversionSettings settings, Model model, string previewDirectory, string baseName) { if (!settings.GeneratePhysics) { @@ -276,37 +283,23 @@ private PhysicsPreviewExport ExportPhysicsPreview(ConversionSettings settings, M model.Name + " Physics", physicsMeshes, [new Material("physics")]); - new GLTFExporter().Export(physicsModel, previewDirectory, physicsBaseName, new GLTFExportOptions(true)); - return new PhysicsPreviewExport(Path.Combine(previewDirectory, physicsBaseName + ".glb"), physicsMeshes.Count); - } - - private IReadOnlyList BuildPhysicsPreviewMeshes(Model model, ConversionSettings settings) - { - return settings.PhysicsMode?.Trim().ToLowerInvariant() switch + var physicsGltfOptions = new ExportOptions(new Dictionary { - "coacd" => BuildCoacdPhysicsPreviewMeshes(model, settings), - _ => [CreateBoundsMesh(model.Bounds().WithMinimumThickness())] - }; + ["binary"] = true, + }); + new GLTFExporter().Export(physicsModel, previewDirectory, physicsBaseName, physicsGltfOptions); + return new PhysicsPreviewExport(Path.Combine(previewDirectory, physicsBaseName + ".glb"), physicsMeshes.Count); } - private IReadOnlyList BuildCoacdPhysicsPreviewMeshes(Model model, ConversionSettings settings) + private static IReadOnlyList BuildPhysicsPreviewMeshes(Model model, ConversionSettings settings) { - var triangleCount = model.Meshes.Sum(mesh => mesh.Triangles.Count()); - if (triangleCount > _maxCoacdPreviewTriangles) - { - logSink.Append( - $"Skipped CoACD physics preview for {triangleCount:N0} render triangles. " + - $"Preview uses bounds above {_maxCoacdPreviewTriangles:N0} triangles; run conversion to build full CoACD physics."); - return [CreateBoundsMesh(model.Bounds().WithMinimumThickness())]; - } - - logSink.Append($"Building CoACD physics preview from {triangleCount:N0} render triangles..."); - return CoacdNative.Decompose( - model.Merge(), - new CoacdDecompositionOptions( - settings.CoacdThreshold, - settings.MaxConvexPieces, - settings.MaxHullVertices)); + // CoACD-based physics preview moved to the Source plugin (which owns CoacdNative). The + // UI's preview path now shows bounds for both modes; the actual export still uses CoACD + // when the user selects "coacd" mode. A follow-up could surface a plugin-contributed + // "preview physics" hook so the UI can render the real shape pre-export, but it's not + // currently in scope. The Mode string is still read so persistence/round-tripping works. + _ = settings.PhysicsMode; + return [CreateBoundsMesh(model.Bounds().WithMinimumThickness())]; } private static Mesh CreateBoundsMesh(Bounds bounds) diff --git a/GMConverter.UI/ViewModels/ConvertViewModel.cs b/GMConverter.UI/ViewModels/ConvertViewModel.cs index b50746d..f38d659 100644 --- a/GMConverter.UI/ViewModels/ConvertViewModel.cs +++ b/GMConverter.UI/ViewModels/ConvertViewModel.cs @@ -117,11 +117,12 @@ internal ConvertViewModel( public ObservableCollection InputFormats { get; } = [ new("opt", "OPT", new OPTImporter().InputName), - new("mdl", "MDL", new MDLImporter().InputName), - // "psk" is plugin-contributed (GMConverter.UnrealEngine). The display name is hardcoded - // here because constructing a plugin importer at UI-init time would require pulling it - // from PluginHost.Registry and handling the not-loaded case. TODO: replace this whole - // static list with a dynamic projection over (built-in importers + registry importers). + // "psk" + "mdl" are plugin-contributed (UnrealEngine + SourceEngine plugins). The display + // names are hardcoded here because constructing a plugin importer at UI-init time would + // require pulling it from PluginHost.Registry and handling the not-loaded case. TODO: + // replace this whole static list with a dynamic projection over (built-in importers + + // PluginHost.Registry.Importers). + new("mdl", "MDL", "Source Engine"), new("psk", "PSK", "Unreal Engine"), new("mow", "MOW", new MOWImporter().InputName) ]; @@ -132,8 +133,10 @@ internal ConvertViewModel( new(new OBJExporter().OutputFormat, "OBJ", new OBJExporter().OutputName), new("glb", "GLB", new GLTFExporter().OutputName), new("gltf", "glTF", new GLTFExporter().OutputName), - new("source", "Source", new MDLExporter().OutputName), - new(new MDLExporter().OutputFormat, "MDL", new MDLExporter().OutputName) + // Source plugin contributes the MDL exporter. Display name hardcoded for the same TODO + // reason as the importer list above. + new("source", "Source", "Source Engine"), + new("mdl", "MDL", "Source Engine") ]; public ObservableCollection AxisModes { get; } = @@ -312,7 +315,7 @@ private static int ParseMaxTextureSize(string value) // new tools/ location instead of carrying a stale absolute path forward. internal void ApplyLocalToolDefaults() { - var (studioMdl, vtfCmd) = GMConverter.Source.SourceToolPaths.TryFindLocalDefaults(); + var (studioMdl, vtfCmd) = TryFindLocalToolDefaults(); if (string.IsNullOrWhiteSpace(StudioMdlPath) && studioMdl is not null) { StudioMdlPath = studioMdl; @@ -323,6 +326,42 @@ internal void ApplyLocalToolDefaults() } } + // Local copy of the path-discovery logic that used to live in GMConverter.Source.SourceToolPaths. + // After Source extraction to a plugin the UI can't reference that type directly, and the UI's + // auto-fill behavior shouldn't depend on the plugin being loaded — these directories follow a + // convention (./tools//...) that's stable across plugin presence. The plugin keeps its + // own copy for the resolve path used at export time. + private static (string? StudioMdl, string? VtfCmd) TryFindLocalToolDefaults() + { + return ( + FindExecutable(GetToolDirectory("studiomdl-ce"), "studiomdl.exe"), + FindExecutable(GetToolDirectory("vtfedit-reloaded"), "VTFCmd.exe")); + } + + private static string GetToolDirectory(string toolName) + { + return Path.Combine(AppContext.BaseDirectory, "tools", toolName); + } + + private static string? FindExecutable(string root, string executableName) + { + if (!Directory.Exists(root)) + { + return null; + } + try + { + return Directory + .EnumerateFiles(root, executableName, SearchOption.AllDirectories) + .FirstOrDefault(); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + _ = ex; + return null; + } + } + internal void TryLoadDefaultConfig() { var defaultPath = UiConfig.FindDefaultPath(); diff --git a/GMConverter.slnx b/GMConverter.slnx index ce8f8ab..c9e7b5f 100644 --- a/GMConverter.slnx +++ b/GMConverter.slnx @@ -3,5 +3,6 @@ + diff --git a/GMConverter/Exporters/GLTFExporter.cs b/GMConverter/Exporters/GLTFExporter.cs index 9553e37..625a8ff 100644 --- a/GMConverter/Exporters/GLTFExporter.cs +++ b/GMConverter/Exporters/GLTFExporter.cs @@ -25,7 +25,7 @@ namespace GMConverter.Exporters; -internal sealed class GLTFExporter : IExporter +internal sealed class GLTFExporter : IExporter { // -90° around X rotates Z-up data into Y-up: (x, y, z) → (x, z, -y). private static readonly Quaternion _zUpToYUpRotation = @@ -35,15 +35,36 @@ internal sealed class GLTFExporter : IExporter public string OutputName => "glTF"; - public void Export(Model model, string outputDirectory, string baseName, GLTFExportOptions options) + public ExporterOptionSchema OptionSchema { get; } = new( + [ + new OptionGroup("output", "Output", + [ + new OptionDescriptor("binary", OptionType.Bool, "Binary (.glb)") + { + DefaultValue = true, + Description = "Emit a single binary .glb file instead of a .gltf + sidecars.", + }, + new OptionDescriptor("bakeUvTransforms", OptionType.Bool, "Bake UV transforms") + { + DefaultValue = false, + Description = "Fold per-material UV scale/offset into mesh UVs at write time. " + + "Used by the in-app preview which does not honor KHR_texture_transform.", + }, + ]), + ]); + + public void Export(Model model, string outputDirectory, string baseName, ExportOptions options) { using var exportScope = PerfTimer.Measure( "gltf.export", "Export", $"meshes={model.Meshes.Count} materials={model.Materials.Count} textures={model.Textures.Count}"); + var binary = options.GetBool("binary", defaultValue: true); + var bakeUvTransforms = options.GetBool("bakeUvTransforms"); + var safeBaseName = NameHelpers.SanitizeFileName(baseName); - var extension = options.Binary ? ".glb" : ".gltf"; + var extension = binary ? ".glb" : ".gltf"; var outputPath = Path.Combine(outputDirectory, $"{safeBaseName}{extension}"); // Per-Export memoization: the same source Texture instance can appear in multiple Materials @@ -55,14 +76,14 @@ public void Export(Model model, string outputDirectory, string baseName, GLTFExp Dictionary materialBuilders; using (PerfTimer.Measure("gltf.export", "BuildMaterials")) { - materialBuilders = BuildMaterials(model, encodeCache, options.BakeUvTransforms); + materialBuilders = BuildMaterials(model, encodeCache, bakeUvTransforms); } // When BakeUvTransforms is set, the consumer can't honor KHR_texture_transform (e.g. the // in-app SharpEngine preview), so we fold each material's BakedUv0Scale into the mesh's UVs // at write time instead. ApplyUvScale in BuildMaterial is skipped in this mode to avoid // double-applying the transform. - var inlineUvScales = options.BakeUvTransforms + var inlineUvScales = bakeUvTransforms ? model.Materials .Where(m => m.BakedUv0Scale is not null) .ToDictionary(m => m.Name, m => m.BakedUv0Scale!.Value, StringComparer.OrdinalIgnoreCase) @@ -133,13 +154,13 @@ public void Export(Model model, string outputDirectory, string baseName, GLTFExp var settings = new WriteSettings { - ImageWriting = options.Binary ? ResourceWriteMode.BufferView : ResourceWriteMode.SatelliteFile, + ImageWriting = binary ? ResourceWriteMode.BufferView : ResourceWriteMode.SatelliteFile, MergeBuffers = true }; - using (PerfTimer.Measure("gltf.export", options.Binary ? "SaveGLB" : "SaveGLTF", outputPath)) + using (PerfTimer.Measure("gltf.export", binary ? "SaveGLB" : "SaveGLTF", outputPath)) { - if (options.Binary) + if (binary) { gltf.SaveGLB(outputPath, settings); } @@ -646,4 +667,3 @@ private static MaterialBuilder ResolveMaterial( } } -internal sealed record GLTFExportOptions(bool Binary = true, bool BakeUvTransforms = false); diff --git a/GMConverter/Exporters/OBJExporter.cs b/GMConverter/Exporters/OBJExporter.cs index d00dc35..6e92828 100644 --- a/GMConverter/Exporters/OBJExporter.cs +++ b/GMConverter/Exporters/OBJExporter.cs @@ -7,7 +7,7 @@ namespace GMConverter.Exporters; -internal sealed class OBJExporter : IExporter +internal sealed class OBJExporter : IExporter { private static readonly UTF8Encoding _utf8NoBom = new(false); @@ -15,8 +15,13 @@ internal sealed class OBJExporter : IExporter public string OutputName => "Wavefront OBJ"; - public void Export(Model model, string outputDirectory, string baseName, OBJExportOptions options) + // OBJ has no user-configurable options today — the exporter writes a .obj + .mtl + texture + // PNGs from the Model with no knobs. Empty schema is the right shape. + public ExporterOptionSchema OptionSchema => ExporterOptionSchema.Empty; + + public void Export(Model model, string outputDirectory, string baseName, ExportOptions options) { + _ = options; var safeBaseName = NameHelpers.SanitizeFileName(baseName); var materialPath = Path.Combine(outputDirectory, $"{safeBaseName}.mtl"); var objPath = Path.Combine(outputDirectory, $"{safeBaseName}.obj"); @@ -165,4 +170,3 @@ private static void WriteObjVector(StreamWriter writer, string prefix, System.Nu } } -internal sealed record OBJExportOptions; diff --git a/GMConverter/GMConverter.csproj b/GMConverter/GMConverter.csproj index 2a5b0b2..44ddc0b 100644 --- a/GMConverter/GMConverter.csproj +++ b/GMConverter/GMConverter.csproj @@ -7,36 +7,10 @@ enable enable true - 1.0.11 - true - - win_amd64 - manylinux_2_17_x86_64.manylinux2014_x86_64 - manylinux_2_17_aarch64.manylinux2014_aarch64 - macosx_11_0_arm64 - - lib_coacd.dll - lib_coacd.so - lib_coacd.so - lib_coacd.dylib - - 4de22f70d1a3fa8c44698c8006a223fe5fb0ee84b76adecf3726cf2003e9145f - a60f700a52e5b40462e508c14bb756cd63ce7e6a95ff72ae0b1592be1dbb0106 - e376fadb22790444c7253f0cee9104a1af01ec965488c1318e84e3b2dbf1e2a3 - adbc58259a721cc5ede24cc8c2671a95e75a8a52dc1ee4d953d80d236b192da9 - - coacd-$(CoacdVersion)-cp39-abi3-$(CoacdPlatformTag).whl - https://github.com/SarahWeiii/CoACD/releases/download/$(CoacdVersion)/$(CoacdWheelFileName) - $(MSBuildProjectExtensionsPath)coacd\$(CoacdVersion)\ - $(CoacdCacheDirectory)extracted\ - $(CoacdCacheDirectory)$(CoacdWheelFileName) - $(CoacdExtractDirectory)coacd\$(CoacdNativeLibraryName) - - @@ -46,42 +20,8 @@ - - - - - - - $(CoacdNativeLibraryName) - $(CoacdNativeLibraryName) - PreserveNewest - PreserveNewest - false - - - - - - - - - - - - - - - - - - - - - - - diff --git a/GMConverter/Geometry/ImageSharpTexture.cs b/GMConverter/Geometry/ImageSharpTexture.cs index 1dc98f7..4b010e2 100644 --- a/GMConverter/Geometry/ImageSharpTexture.cs +++ b/GMConverter/Geometry/ImageSharpTexture.cs @@ -34,6 +34,13 @@ public ImageSharpTexture(string name, Image image, bool hasAlpha = false public override string DebugDimensions => $"{_image.Width}x{_image.Height} pixel={typeof(Rgba32).Name}"; + public override byte[] GetRgbaPixels() + { + var output = new byte[_image.Width * _image.Height * 4]; + _image.CopyPixelDataTo(output); + return output; + } + public ImageSharpTexture WithOpenGlNormalMap(string? textureName = null) { var output = _image.Clone(_ => { }); diff --git a/GMConverter/Geometry/TextureExtensions.cs b/GMConverter/Geometry/TextureExtensions.cs index 25de0a5..1152c2f 100644 --- a/GMConverter/Geometry/TextureExtensions.cs +++ b/GMConverter/Geometry/TextureExtensions.cs @@ -20,12 +20,6 @@ public static ImageSharpTexture ToGltfMetallicRoughness(this Texture texture, st public static ImageSharpTexture ToSpecularFactorMask(this Texture texture, string? textureName = null) => RequireImageSharp(texture).ToSpecularFactorMask(textureName); - public static ImageSharpTexture ToSourcePhongExponent(this Texture texture, string? textureName = null) - => RequireImageSharp(texture).ToSourcePhongExponent(textureName); - - public static ImageSharpTexture WithMaskInAlpha(this Texture texture, Texture mask, string? textureName = null) - => RequireImageSharp(texture).WithMaskInAlpha(RequireImageSharp(mask), textureName); - private static ImageSharpTexture RequireImageSharp(Texture texture) { return texture as ImageSharpTexture diff --git a/GMConverter/Plugins/DefaultPluginContext.cs b/GMConverter/Plugins/DefaultPluginContext.cs index 0e4d0c2..3d6146a 100644 --- a/GMConverter/Plugins/DefaultPluginContext.cs +++ b/GMConverter/Plugins/DefaultPluginContext.cs @@ -9,7 +9,7 @@ namespace GMConverter.Plugins; internal sealed class DefaultPluginContext : IPluginContext { private readonly List _importers = []; - private readonly List _exporters = []; + private readonly List _exporters = []; private readonly List _explorers = []; public DefaultPluginContext(IServiceProvider services) @@ -21,7 +21,7 @@ public DefaultPluginContext(IServiceProvider services) public IReadOnlyList RegisteredImporters => _importers; - public IReadOnlyList RegisteredExporters => _exporters; + public IReadOnlyList RegisteredExporters => _exporters; public IReadOnlyList RegisteredExplorers => _explorers; @@ -36,13 +36,13 @@ public void RegisterImporter() where T : class, IImporter _importers.Add(ActivatorUtilities.CreateInstance(Services)); } - public void RegisterExporter(IExporterDescriptor exporter) + public void RegisterExporter(IExporter exporter) { ArgumentNullException.ThrowIfNull(exporter); _exporters.Add(exporter); } - public void RegisterExporter() where T : class, IExporterDescriptor + public void RegisterExporter() where T : class, IExporter { _exporters.Add(ActivatorUtilities.CreateInstance(Services)); } diff --git a/GMConverter/Plugins/PluginRegistry.cs b/GMConverter/Plugins/PluginRegistry.cs index 6e77a8f..9c457a8 100644 --- a/GMConverter/Plugins/PluginRegistry.cs +++ b/GMConverter/Plugins/PluginRegistry.cs @@ -14,7 +14,7 @@ public sealed class PluginRegistry { private readonly List _plugins = []; private readonly Dictionary _importersByFormat = new(StringComparer.OrdinalIgnoreCase); - private readonly Dictionary _exportersByFormat = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _exportersByFormat = new(StringComparer.OrdinalIgnoreCase); private readonly List _explorers = []; public static PluginRegistry Empty { get; } = new(); @@ -26,7 +26,7 @@ public sealed class PluginRegistry public IImporter? GetImporter(string inputFormat) => _importersByFormat.TryGetValue(inputFormat, out var importer) ? importer : null; - public IExporterDescriptor? GetExporter(string outputFormat) => + public IExporter? GetExporter(string outputFormat) => _exportersByFormat.TryGetValue(outputFormat, out var exporter) ? exporter : null; internal void Add(LoadedPlugin loaded)