From 9b61f64424e753b0ef032dadeaf660bdeb2e845a Mon Sep 17 00:00:00 2001 From: dhkatz Date: Mon, 25 May 2026 16:48:27 -0700 Subject: [PATCH] refactor(sourceengine): extract Source Engine into a plugin + add schema-driven exporter contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Source Engine support now lives in a standalone GMConverter.SourceEngine plugin assembly. Core no longer contains MDL importer/exporter code, the Source/ directory, the MdlCrowbar package reference, or the CoACD MSBuild target. The SDK exporter contract also moves to a schema-driven shape that the generic UI panel (follow-up PR) will render directly. Plugin extraction - New GMConverter.SourceEngine project (net10.0, library) referencing only GMConverter.SDK + MdlCrowbar + ImageSharp. 12 files moved from Core: MDLImporter, MDLExporter, all of Source/{GameInfo,MaterialOptimizationOptions, PhysicsOptions,SourceMaterialCompiler,SourceMaterialSurfaceProps, SourcePhongSettings,SourceToolPaths}, plus the UE-side-but-Source-specific helpers ProcessRunner, CoacdNative, CoacdDecompositionOptions. - The plugin's CoACD MSBuild download target moved with it; lib_coacd is resolved at runtime via PluginLoadContext's sibling-folder fallback (introduced in #26 for CUE4Parse-Natives). - SourceEnginePlugin : IPlugin registers MDLImporter and MDLExporter via DI; both receive ITextureFactory via constructor injection. - plugin.json id is "gmconverter.sourceengine". - Source-specific texture transforms (ToSourcePhongExponent, WithMaskInAlpha) reimplemented in the plugin against the SDK's new Texture.GetRgbaPixels() surface + ITextureFactory.FromRgba(). Core's TextureExtensions keeps the general-purpose transforms (ToGltfMetallicRoughness, WithOpenGlNormalMap, ToSpecularFactorMask). Schema-driven exporter contract - New SDK types: OptionType (enum), OptionDescriptor (data, with deferred-default factory hook), OptionGroup, ExporterOptionSchema, ExportOptions (typed-accessor bag). IExporter and IExporterDescriptor collapsed into a single non-generic IExporter exposing OptionSchema + Export(Model, string, string, ExportOptions). Plugins read what they need from the bag and assemble their own internal typed options. - MDLExporter declares the full ~12-option schema across Tools / Materials / Physics groups. GLTFExporter declares a minimal {binary, bakeUvTransforms} schema; OBJExporter declares ExporterOptionSchema.Empty. Host bundling - GMConverter.CLI.csproj and GMConverter.UI.csproj both add a entry for SourceEngine (same shape as the existing UnrealEngine entry). The CopyBundledPlugins target stages the plugin's bin into bin/.../plugins/sourceengine/, where PluginLoader picks it up at startup. - The host's MdlCrowbar package ref is removed from GMConverter/GMConverter.csproj. Host-side surface cleanup - CLI Program.cs and UI ConversionService.cs no longer reference plugin-private Source types (PhysicsOptions, PhysicsMode, CoacdOptions, SourceToolPaths, MDLExporter, MDLImporter). Both build ExportOptions bags from primitive inputs and look up the exporter via PluginHost.Registry.GetExporter("mdl"). Failing that, they surface a clear error pointing at the missing plugin. - ConvertViewModel's auto-fill of StudioMDL / VTFCmd paths uses a small local copy of the path-discovery convention (./tools//...) instead of calling into the plugin. The plugin keeps its own copy for the resolve path used at export time. - UI's CoACD physics preview removed; preview falls back to a bounds visualisation for both modes. Actual export still produces CoACD physics when the user picks that mode. Surfacing a plugin-contributed preview hook is a follow-up. Empirically verified - CLI smoke: runs against an empty test.psk, plugin log line "Loaded plugin gmconverter.sourceengine v0.1.0" appears, importer error surfaces as expected. - Plugin deploy: bin/Release/net10.0/plugins/sourceengine/ contains GMConverter.SourceEngine.dll, MdlCrowbar.dll, lib_coacd.dll, plugin.json and the rest of the runtime deps. - dotnet build GMConverter.slnx -c Release → 0 warnings, 0 errors - dotnet format --verify-no-changes → clean Deferred to follow-up - Generic schema-driven UI panel (ExporterOptionsPanel.axaml + DataTemplates + ExporterOptionsViewModel). The existing SourceEngineSettings.axaml panel still binds ConvertViewModel's typed Source properties; ConversionService pipes those into the ExportOptions bag at runtime. The schema is the source of truth for keys and defaults but rendering remains bespoke for Source until the generic panel work lands. - CLI dynamic argument construction from the schema (the schema declares the options but the CLI still has hand-rolled --studiomdl-path etc. arguments). - Manual UE4/UE5/Source runtime verification against real archives — same gap as #25. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 5 +- GMConverter.CLI/GMConverter.CLI.csproj | 3 + GMConverter.CLI/Program.cs | 105 ++++++------- GMConverter.SDK/Exporters/ExportOptions.cs | 107 +++++++++++++ .../Exporters/ExporterOptionSchema.cs | 22 +++ GMConverter.SDK/Exporters/IExporter.cs | 25 +++- .../Exporters/IExporterDescriptor.cs | 13 -- GMConverter.SDK/Exporters/OptionDescriptor.cs | 48 ++++++ GMConverter.SDK/Exporters/OptionGroup.cs | 13 ++ GMConverter.SDK/Exporters/OptionType.cs | 35 +++++ GMConverter.SDK/Plugins/IPluginContext.cs | 4 +- GMConverter.SDK/Textures/Texture.cs | 9 ++ .../Common/ProcessRunner.cs | 2 +- .../Exporters/MDLExporter.cs | 140 ++++++++++++++++-- .../GMConverter.SourceEngine.csproj | 92 ++++++++++++ .../GameInfo.cs | 2 +- .../Geometry/CoacdDecompositionOptions.cs | 2 +- .../Geometry/CoacdNative.cs | 2 +- .../Importers/MDLImporter.cs | 2 +- .../MaterialOptimizationOptions.cs | 2 +- .../PhysicsOptions.cs | 2 +- .../SourceEnginePlugin.cs | 30 ++++ .../SourceMaterialCompiler.cs | 15 +- .../SourceMaterialSurfaceProps.cs | 2 +- .../SourcePhongSettings.cs | 2 +- .../SourceTextureTransforms.cs | 93 ++++++++++++ .../SourceToolPaths.cs | 2 +- GMConverter.SourceEngine/plugin.json | 11 ++ GMConverter.UI/GMConverter.UI.csproj | 3 + GMConverter.UI/Services/ConversionService.cs | 127 ++++++++-------- GMConverter.UI/ViewModels/ConvertViewModel.cs | 55 ++++++- GMConverter.slnx | 1 + GMConverter/Exporters/GLTFExporter.cs | 38 +++-- GMConverter/Exporters/OBJExporter.cs | 10 +- GMConverter/GMConverter.csproj | 60 -------- GMConverter/Geometry/ImageSharpTexture.cs | 7 + GMConverter/Geometry/TextureExtensions.cs | 6 - GMConverter/Plugins/DefaultPluginContext.cs | 8 +- GMConverter/Plugins/PluginRegistry.cs | 4 +- 39 files changed, 844 insertions(+), 265 deletions(-) create mode 100644 GMConverter.SDK/Exporters/ExportOptions.cs create mode 100644 GMConverter.SDK/Exporters/ExporterOptionSchema.cs delete mode 100644 GMConverter.SDK/Exporters/IExporterDescriptor.cs create mode 100644 GMConverter.SDK/Exporters/OptionDescriptor.cs create mode 100644 GMConverter.SDK/Exporters/OptionGroup.cs create mode 100644 GMConverter.SDK/Exporters/OptionType.cs rename {GMConverter => GMConverter.SourceEngine}/Common/ProcessRunner.cs (96%) rename {GMConverter => GMConverter.SourceEngine}/Exporters/MDLExporter.cs (84%) create mode 100644 GMConverter.SourceEngine/GMConverter.SourceEngine.csproj rename {GMConverter/Source => GMConverter.SourceEngine}/GameInfo.cs (99%) rename {GMConverter => GMConverter.SourceEngine}/Geometry/CoacdDecompositionOptions.cs (73%) rename {GMConverter => GMConverter.SourceEngine}/Geometry/CoacdNative.cs (99%) rename {GMConverter => GMConverter.SourceEngine}/Importers/MDLImporter.cs (99%) rename {GMConverter/Source => GMConverter.SourceEngine}/MaterialOptimizationOptions.cs (94%) rename {GMConverter/Source => GMConverter.SourceEngine}/PhysicsOptions.cs (87%) create mode 100644 GMConverter.SourceEngine/SourceEnginePlugin.cs rename {GMConverter/Source => GMConverter.SourceEngine}/SourceMaterialCompiler.cs (96%) rename {GMConverter/Source => GMConverter.SourceEngine}/SourceMaterialSurfaceProps.cs (91%) rename {GMConverter/Source => GMConverter.SourceEngine}/SourcePhongSettings.cs (92%) create mode 100644 GMConverter.SourceEngine/SourceTextureTransforms.cs rename {GMConverter/Source => GMConverter.SourceEngine}/SourceToolPaths.cs (99%) create mode 100644 GMConverter.SourceEngine/plugin.json 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)