diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..8284c7fe6a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,114 @@ +## General Project Information + +- Language: **C# targeting .NET 8** (desktop application). +- This is a level editor suite for a family of 3D game engines used in the classic Tomb Raider series. +- Level formats are grid-based, room-based, and portal-based. +- A room is a spatial container for level geometry and game entities. +- Rooms are connected by vertical or horizontal portals, strictly aligned with grid sectors. +- Portals may be visual (`RoomConnectionInfo.VisualType`) or traversable (`RoomConnectionInfo.TraversableType`). +- One grid sector consists of 1024 units, which roughly equals 2 meters in real-world coordinates. + +## General Guidelines + +- Files must use Windows line endings. Only standard ASCII symbols are allowed; do not use Unicode symbols. +- `using` directives are grouped and sorted as follows: `DarkUI` namespaces first, then `System` namespaces, followed by third-party and local namespaces. +- Namespace declarations and type definitions should place the opening brace on a new line. +- Prefer grouping all feature-related functionality within a self-contained module or modules. Avoid creating large code blocks over 10–15 lines in existing modules; instead, offload code to helper functions. +- Avoid duplicating and copypasting code. Implement helper methods instead, whenever similar code is used within a given module, class or feature scope. + +## Formatting + +- **Indentation** is four spaces; tabs are not used. + +- **Braces**: + - Always use braces for multi-statement blocks. + - Do not use braces for single-statement blocks, unless they are within multiple `else if` conditions where surrounding statements are multi-line. + + - Opening curly brace `{` for structures, classes and methods should be on the next line, not on the same line: + + ```csharp + public class Foo + { + public void Bar() + { + if (condition) + { + ... + } + } + } + ``` + + - Anonymous delegates and lambdas should keep the brace on the same line: + `delegate () { ... }` or `() => { ... }`. + +- **Line breaks and spacing**: + - A blank line separates logically distinct groups of members (fields, constructors, public methods, private helpers, etc.). + - Spaces around binary operators (`=`, `+`, `==`, etc.) and after commas. + - A single space follows keyword `if`/`for`/`while` before the opening parenthesis. + - Expressions may be broken into multiple lines and aligned with the previous line's indentation level to improve readability. + - However, chained LINQ method calls, lambdas or function/method arguments should not be broken into multiple lines, unless they reach more than 150 symbols in length. + + - Do not collapse early exits or single-statement conditions into a single line: + + Bad example: + ```csharp + if (condition) return; + ``` + Do this instead: + ```csharp + if (condition) + return; + ``` + +## Naming + +- **PascalCase** for public types, methods, constants, properties and events. +- **camelCase** for private fields and local variables. Private fields should start with an underscore (`_editor`, `_primaryControlFocused`). Local variables should not start with an underscore. +- Constants and `static readonly` fields use PascalCase rather than ALL_CAPS. +- Enum members use PascalCase. +- Interfaces are prefixed with `I` and use PascalCase (`IScaleable`). +- Methods and variables should use clear, descriptive names and generally avoid Hungarian notation. Avoid using short non-descriptive names, such as `s2`, `rwh`, `fmp`, unless underlying meaning is brief (e.g. X coordinate is `x`, counter is `i`). +- Class method and field names should not repeat words from a class name itself (e.g. `ObjectBrushHelper.BeginObjectBrushStroke` is a bad name, but `ObjectBrushHelper.BeginStroke` is a good name). + +## Members and Access + +- Fields are generally declared as `public` or `private readonly` depending on usage; expose state via properties where appropriate. +- `var` type should be preferred where possible, when the right-hand type is evident from the initializer. +- Explicit typing should be only used when it is required by logic or compiler, or when type name is shorter than 6 symbols (e.g. `int`, `bool`, `float`). +- For floating-point numbers, always use `f` postfix and decimal, even if value is not fractional (e.g. `2.0f`). + +## Control Flow and Syntax + +- Avoid excessive condition nesting and use early exits / breaks where possible. +- LINQ and lambda expressions are used for collections (`FirstOrDefault`, `Where`, `Any`, etc.). +- Exception and error handling is done with `try`/`catch`, and caught exceptions are logged with [NLog](https://nlog-project.org/) where appropriate. +- Warnings must also be logged by NLog, if cause for the incorrect behaviour is user action. + +## Comments + +- When comments appear they are single-line `//`. Block comments (`/* ... */`) are rare. +- Comments are sparse. Code relies on meaningful names rather than inline documentation. +- Do not use `` if surrounding code and/or module isn't already using it. Only add `` for non-private methods with high complexity. +- If module or function implements complex functionality, a brief description (2-3 lines) may be added in front of it, separated by a blank line from the function body. +- All descriptive comments should end with a full stop (`.`). + +## Code Grouping + +- Large methods should group related actions together, separated by blank lines. +- Constants and static helpers that are used several times should appear at the top of a class. +- Constants that are used only within a scope of a method, should be declared within this method. +- One-liner lambdas may be grouped together, if they share similar meaning or functionality. + +## User Interface Implementation + +- For WinForms-based workflows, maintain the existing Visual Studio module pair for each control or unit: `.cs` and `.Designer.cs`. +- For existing WinForms-based `DarkUI` controls and containers, prefer to use existing WinForms-based `DarkUI` controls. +- For new controls and containers with complex logic, or where WinForms may not perform fast enough, prefer `DarkUI.WPF` framework. Use `GeometryIOSettingsWindow` as a reference. +- Use `CommunityToolkit` functionality where possible. + +## Performance + +- For 3D rendering controls, prefer more performant approaches and locally cache frequently used data within the function scope whenever possible. +- Avoid scenarios where bulk data updates may cause event floods, as the project relies heavily on event subscriptions across multiple controls and sub-controls. +- Use `Parallel` for bulk operations to maximize performance. Avoid using it in thread-unsafe contexts or when operating on serial data sets. \ No newline at end of file diff --git a/DarkUI/DarkUI.WPF/Converters/InverseBoolToVisibilityConverter.cs b/DarkUI/DarkUI.WPF/Converters/InverseBoolToVisibilityConverter.cs new file mode 100644 index 0000000000..f62db0220d --- /dev/null +++ b/DarkUI/DarkUI.WPF/Converters/InverseBoolToVisibilityConverter.cs @@ -0,0 +1,15 @@ +using System; +using System.Globalization; +using System.Windows; +using System.Windows.Data; + +namespace DarkUI.WPF.Converters; + +public class InverseBoolToVisibilityConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + => value is bool b && b ? Visibility.Collapsed : Visibility.Visible; + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => throw new NotSupportedException(); +} diff --git a/DarkUI/DarkUI.WPF/CustomControls/SpacedGrid.cs b/DarkUI/DarkUI.WPF/CustomControls/SpacedGrid.cs new file mode 100644 index 0000000000..289f3f3c3a --- /dev/null +++ b/DarkUI/DarkUI.WPF/CustomControls/SpacedGrid.cs @@ -0,0 +1,130 @@ +using System; +using System.Windows; +using System.Windows.Controls; + +namespace DarkUI.WPF.CustomControls; + +/// +/// A Grid that supports RowSpacing and ColumnSpacing between rows and columns +/// without injecting dummy rows or columns. +/// +public class SpacedGrid : Grid +{ + public static readonly DependencyProperty RowSpacingProperty = + DependencyProperty.Register( + nameof(RowSpacing), + typeof(double), + typeof(SpacedGrid), + new FrameworkPropertyMetadata(0.0, FrameworkPropertyMetadataOptions.AffectsMeasure | FrameworkPropertyMetadataOptions.AffectsArrange)); + + public static readonly DependencyProperty ColumnSpacingProperty = + DependencyProperty.Register( + nameof(ColumnSpacing), + typeof(double), + typeof(SpacedGrid), + new FrameworkPropertyMetadata(0.0, FrameworkPropertyMetadataOptions.AffectsMeasure | FrameworkPropertyMetadataOptions.AffectsArrange)); + + /// + /// Gets or sets the vertical spacing between rows. + /// + public double RowSpacing + { + get => (double)GetValue(RowSpacingProperty); + set => SetValue(RowSpacingProperty, value); + } + + /// + /// Gets or sets the horizontal spacing between columns. + /// + public double ColumnSpacing + { + get => (double)GetValue(ColumnSpacingProperty); + set => SetValue(ColumnSpacingProperty, value); + } + + protected override Size MeasureOverride(Size constraint) + { + double totalRowSpacing = GetTotalRowSpacing(); + double totalColSpacing = GetTotalColumnSpacing(); + + var reducedConstraint = new Size( + double.IsInfinity(constraint.Width) ? constraint.Width : Math.Max(0, constraint.Width - totalColSpacing), + double.IsInfinity(constraint.Height) ? constraint.Height : Math.Max(0, constraint.Height - totalRowSpacing)); + + var baseSize = base.MeasureOverride(reducedConstraint); + + return new Size(baseSize.Width + totalColSpacing, baseSize.Height + totalRowSpacing); + } + + protected override Size ArrangeOverride(Size arrangeSize) + { + double rowSpacing = RowSpacing; + double colSpacing = ColumnSpacing; + double totalRowSpacing = GetTotalRowSpacing(); + double totalColSpacing = GetTotalColumnSpacing(); + + // Let the base Grid arrange children within a reduced area. + var reducedSize = new Size( + Math.Max(0, arrangeSize.Width - totalColSpacing), + Math.Max(0, arrangeSize.Height - totalRowSpacing)); + + base.ArrangeOverride(reducedSize); + + int rowCount = Math.Max(1, RowDefinitions.Count); + int colCount = Math.Max(1, ColumnDefinitions.Count); + + // Build cumulative row offsets from ActualHeight after base layout. + double[] rowStarts = new double[rowCount + 1]; + + for (int i = 0; i < rowCount; i++) + { + double height = RowDefinitions.Count > 0 ? RowDefinitions[i].ActualHeight : reducedSize.Height; + rowStarts[i + 1] = rowStarts[i] + height; + } + + // Build cumulative column offsets from ActualWidth after base layout. + double[] colStarts = new double[colCount + 1]; + + for (int i = 0; i < colCount; i++) + { + double width = ColumnDefinitions.Count > 0 ? ColumnDefinitions[i].ActualWidth : reducedSize.Width; + colStarts[i + 1] = colStarts[i] + width; + } + + // Re-arrange each child, injecting spacing offsets. + foreach (UIElement child in InternalChildren) + { + if (child == null || child.Visibility == Visibility.Collapsed) + continue; + + int row = Math.Min(GetRow(child), rowCount - 1); + int col = Math.Min(GetColumn(child), colCount - 1); + int rowSpan = Math.Min(GetRowSpan(child), rowCount - row); + int colSpan = Math.Min(GetColumnSpan(child), colCount - col); + + // Position = base offset + cumulative spacing for preceding rows/columns. + double x = colStarts[col] + col * colSpacing; + double y = rowStarts[row] + row * rowSpacing; + + // Size = base span size + spacing gaps within the span. + double width = colStarts[col + colSpan] - colStarts[col] + (colSpan - 1) * colSpacing; + double height = rowStarts[row + rowSpan] - rowStarts[row] + (rowSpan - 1) * rowSpacing; + + child.Arrange(new Rect(x, y, Math.Max(0, width), Math.Max(0, height))); + } + + return arrangeSize; + } + + private double GetTotalRowSpacing() + { + int gaps = Math.Max(0, (RowDefinitions.Count > 0 ? RowDefinitions.Count : 1) - 1); + return gaps * RowSpacing; + } + + private double GetTotalColumnSpacing() + { + int gaps = Math.Max(0, (ColumnDefinitions.Count > 0 ? ColumnDefinitions.Count : 1) - 1); + return gaps * ColumnSpacing; + } +} diff --git a/DarkUI/DarkUI.WPF/Extensions/DependencyObjectExtensions.cs b/DarkUI/DarkUI.WPF/Extensions/DependencyObjectExtensions.cs index f6aff98bab..e96c1ac16a 100644 --- a/DarkUI/DarkUI.WPF/Extensions/DependencyObjectExtensions.cs +++ b/DarkUI/DarkUI.WPF/Extensions/DependencyObjectExtensions.cs @@ -16,6 +16,14 @@ public static class DependencyObjectExtensions return ancestor as T; } + public static T? FindVisualAncestorOrSelf(this DependencyObject dependencyObject) where T : class + { + if (dependencyObject is T self) + return self; + + return dependencyObject.FindVisualAncestor(); + } + public static T? FindLogicalAncestor(this DependencyObject dependencyObject) where T : class { DependencyObject ancestor = dependencyObject; diff --git a/DarkUI/DarkUI.WPF/Generic.xaml b/DarkUI/DarkUI.WPF/Generic.xaml index 11820d9ed7..80f8bcbd08 100644 --- a/DarkUI/DarkUI.WPF/Generic.xaml +++ b/DarkUI/DarkUI.WPF/Generic.xaml @@ -19,6 +19,7 @@ + diff --git a/DarkUI/DarkUI.WPF/Styles/Slider.xaml b/DarkUI/DarkUI.WPF/Styles/Slider.xaml new file mode 100644 index 0000000000..59f1b7bfeb --- /dev/null +++ b/DarkUI/DarkUI.WPF/Styles/Slider.xaml @@ -0,0 +1,70 @@ + + + + + + + + diff --git a/Installer/Changes.txt b/Installer/Changes.txt index a351e8f4c9..e1a5a59b57 100644 --- a/Installer/Changes.txt +++ b/Installer/Changes.txt @@ -10,29 +10,35 @@ Tomb Editor: * Added support for triangular geometry in TR1X and TR2X levels. * Added a texture depth option (8/16/32-bit) for TR1X and TR2X levels. * Added support for TR1X and TR2X levels to use more sound slots (maximum 1000 samples in total per level). + * Added support for monkeyswing in TR1X and TR2X levels. * Added support for no caustics room flag in TEN. * Changed TR2X levels to embed sound effects rather than using main.sfx. * Fixed node parameter corruption after changing node background color. + * Fixed unintended room deletion in 3D mode when no objects are selected. + * Fixed enemy monkeyswing pathfinding being added to TR3 levels. WadTool: * Added indication of missing external texture file. * Automatically correct hair mesh connections when importing TR4 outfits to TEN wad. * Rearranged animation editor UI in preparation for animation blending feature. * Fixed gizmo behaviour in animation editor. + * Fixed Y/P numerical rotation controls being reversed. * Fixed misplaced backholster meshes for classic engines. * Fixed external textures not loading when wad was moved to another directory. * Fixed exception while loading wads with material xml files placed in the same folder as wad. * Fixed exception while exporting meshes with degenerate textures. TombIDE: - * Updated TR1X and TR2X presets to TRX 1.2.2. - * Updated PLAY.exe to fix TEN console window not showing in `-debug` mode. * Updated "Ready to Play" archive creation dialog. + * Updated TR1X and TR2X presets to TRX 1.3.1. + * Updated PLAY.exe to fix TEN console window not showing in `-debug` mode. + * Updated FLEP. TEN nodes: * Added nodes to check the color of a moveable or static mesh. * Added nodes to show or hide interaction highlight for a moveable. * Added nodes to use inventory item, set inventory item count and to reset inventory to default. + * Added a node to clear all input keys. * Fixed crash with keypad nodes on certain system configurations. Version 1.10.1 diff --git a/Installer/install_script_NET6_x64.nsi b/Installer/install_script_NET6_x64.nsi index fb43eff05d..9081fa5b9f 100644 --- a/Installer/install_script_NET6_x64.nsi +++ b/Installer/install_script_NET6_x64.nsi @@ -527,7 +527,9 @@ Section "Uninstall" Delete "$INSTDIR\ColorThief.Netstandard.v20.dll" Delete "$INSTDIR\bzPSD.dll" Delete "$INSTDIR\Blake3.dll" - Delete "$INSTDIR\BCnEncoder.dll" + Delete "$INSTDIR\DirectXTexNet.dll" + Delete "$INSTDIR\runtimes\win-x64\native\DirectXTexNetImpl.dll" + Delete "$INSTDIR\runtimes\win-x64\native\Ijwhost.dll" Delete "$INSTDIR\AssimpNet.dll" RMDir "$INSTDIR\TIDE\Templates\Defaults\TR4 Resources" RMDir "$INSTDIR\TIDE\Templates\Defaults\Game Icons" diff --git a/LuaApiBuilder/ApiConverter.cs b/LuaApiBuilder/ApiConverter.cs index 2d581ea079..32a8ea3562 100644 --- a/LuaApiBuilder/ApiConverter.cs +++ b/LuaApiBuilder/ApiConverter.cs @@ -1,3 +1,4 @@ +using LuaApiBuilder.Interfaces; using LuaApiBuilder.Objects; using System.Text; using System.Text.RegularExpressions; @@ -246,7 +247,7 @@ private void GenerateClass(StringBuilder builder, ApiClass apiClass, string modu // Generate fields foreach (var field in apiClass.Fields) - builder.AppendLine($"---@field {field.Name} {MapType(field.Type)} # {CleanDescription(field.Summary)}"); + GenerateFieldAnnotation(builder, field); // Generate the direct class name first builder.AppendLine($"{className} = {{}}"); @@ -424,15 +425,33 @@ private void GenerateMethodForClass(StringBuilder builder, ApiMethod method, str } else { - var methodName = method.Name.Replace($"{className}:", string.Empty); + char separator = method.Name.StartsWith($"{className}.") ? '.' : ':'; + var methodName = method.Name.Replace($"{className}{separator}", string.Empty); if (useModulePrefix) - builder.AppendLine($"function {moduleName}.{className}:{methodName}({paramNames}) end"); + builder.AppendLine($"function {moduleName}.{className}{separator}{methodName}({paramNames}) end"); else - builder.AppendLine($"function {className}:{methodName}({paramNames}) end"); + builder.AppendLine($"function {className}{separator}{methodName}({paramNames}) end"); } } + /// + /// Generates a field annotation for Lua Language Server, handling optional fields and default values. + /// + /// The string builder to append to. + /// The field to generate annotation for. + private void GenerateFieldAnnotation(StringBuilder builder, ApiField field) + { + var fieldType = MapType(field.Type); + + var rawDescription = string.IsNullOrWhiteSpace(field.Summary) ? field.Description : field.Summary; + var description = CleanDescription(rawDescription); + + ApplyOptionalFormatting(field, ref fieldType, ref description, "Optional field"); + + builder.AppendLine($"---@field {field.Name} {fieldType} # {description}"); + } + /// /// Generates a parameter annotation for Lua Language Server, handling optional parameters and default values. /// @@ -444,32 +463,40 @@ private void GenerateParameterAnnotation(StringBuilder builder, ApiParameter par var description = CleanDescription(param.Description); var paramName = EscapeLuaReservedKeyword(param.Name); - // Handle optional parameters - if (param.Optional) - { - // Make the type optional by appending '?' - if (!paramType.EndsWith("?")) - paramType += "?"; + ApplyOptionalFormatting(param, ref paramType, ref description, "Optional parameter"); - // Add default value information to description if available - if (!string.IsNullOrWhiteSpace(param.DefaultValue)) - { - if (!string.IsNullOrWhiteSpace(description)) - description += $" (default: `{param.DefaultValue}`)"; - else - description = $"Default: `{param.DefaultValue}`"; - } + builder.AppendLine($"---@param {paramName} {paramType} # {description}"); + } + + /// + /// Applies optional/default-value formatting to a type string and description, shared by field and parameter annotations. + /// + /// The optional object supplying and . + /// The mapped type string; will have '?' appended when optional. + /// The cleaned description; will have default or optional information appended. + /// Label to use when the object is optional but has no description (e.g. "Optional field" or "Optional parameter"). + private static void ApplyOptionalFormatting(IOptionalObject obj, ref string type, ref string description, string fallbackLabel) + { + if (!obj.Optional) + return; + + if (!type.EndsWith("?")) + type += "?"; + + if (!string.IsNullOrWhiteSpace(obj.DefaultValue)) + { + if (!string.IsNullOrWhiteSpace(description)) + description += $" (default: `{obj.DefaultValue}`)"; else - { - // If no default value is specified but parameter is optional, indicate it's optional - if (!string.IsNullOrWhiteSpace(description)) - description += " (optional)"; - else - description = "Optional parameter"; - } + description = $"Default: `{obj.DefaultValue}`"; + } + else + { + if (!string.IsNullOrWhiteSpace(description)) + description += " (optional)"; + else + description = fallbackLabel; } - - builder.AppendLine($"---@param {paramName} {paramType} # {description}"); } /// diff --git a/LuaApiBuilder/ApiXmlParser.cs b/LuaApiBuilder/ApiXmlParser.cs index 13c6cbb408..7d623b9c63 100644 --- a/LuaApiBuilder/ApiXmlParser.cs +++ b/LuaApiBuilder/ApiXmlParser.cs @@ -126,7 +126,9 @@ private static ApiClass ParseClass(XElement cls) Name = field.Element("name")?.Value ?? string.Empty, Type = field.Element("type")?.Value ?? string.Empty, Summary = field.Element("summary")?.Value ?? string.Empty, - Description = field.Element("description")?.Value ?? string.Empty + Description = field.Element("description")?.Value ?? string.Empty, + Optional = bool.TryParse(field.Element("optional")?.Value, out var fieldOptional) && fieldOptional, + DefaultValue = field.Element("defaultValue")?.Value ?? string.Empty }; if (string.IsNullOrWhiteSpace(apiField.Type)) diff --git a/LuaApiBuilder/Interfaces/IOptionalObject.cs b/LuaApiBuilder/Interfaces/IOptionalObject.cs new file mode 100644 index 0000000000..527d365697 --- /dev/null +++ b/LuaApiBuilder/Interfaces/IOptionalObject.cs @@ -0,0 +1,17 @@ +namespace LuaApiBuilder.Interfaces; + +/// +/// Represents an object that can be optional, with an optional default value. +/// +public interface IOptionalObject +{ + /// + /// Indicates whether this object is optional. + /// + bool Optional { get; set; } + + /// + /// The default value for this object, if it is optional. + /// + string DefaultValue { get; set; } +} diff --git a/LuaApiBuilder/Objects/ApiField.cs b/LuaApiBuilder/Objects/ApiField.cs index ce13ed37c0..09215c60fc 100644 --- a/LuaApiBuilder/Objects/ApiField.cs +++ b/LuaApiBuilder/Objects/ApiField.cs @@ -2,10 +2,12 @@ namespace LuaApiBuilder.Objects; -public sealed class ApiField : INamedObject, ITypedObject, ISummarizedObject, IDescribedObject +public sealed class ApiField : INamedObject, ITypedObject, ISummarizedObject, IDescribedObject, IOptionalObject { public string Name { get; set; } = string.Empty; public string Type { get; set; } = string.Empty; public string Summary { get; set; } = string.Empty; public string Description { get; set; } = string.Empty; + public bool Optional { get; set; } + public string DefaultValue { get; set; } = string.Empty; } diff --git a/LuaApiBuilder/Objects/ApiParameter.cs b/LuaApiBuilder/Objects/ApiParameter.cs index 3e445f6226..d6081ca167 100644 --- a/LuaApiBuilder/Objects/ApiParameter.cs +++ b/LuaApiBuilder/Objects/ApiParameter.cs @@ -2,19 +2,11 @@ namespace LuaApiBuilder.Objects; -public sealed class ApiParameter : INamedObject, ITypedObject, IDescribedObject +public sealed class ApiParameter : INamedObject, ITypedObject, IDescribedObject, IOptionalObject { public string Name { get; set; } = string.Empty; public string Type { get; set; } = string.Empty; public string Description { get; set; } = string.Empty; - - /// - /// Indicates whether this parameter is optional. - /// public bool Optional { get; set; } - - /// - /// The default value for this parameter, if it is optional. - /// public string DefaultValue { get; set; } = string.Empty; } diff --git a/TombEditor/Command.cs b/TombEditor/Command.cs index 1999075f17..06ca63f345 100644 --- a/TombEditor/Command.cs +++ b/TombEditor/Command.cs @@ -180,6 +180,11 @@ static CommandHandler() args.Editor.Mode = EditorMode.Lighting; }); + AddCommand("SwitchObjectPlacementMode", "Switch to Object Placement mode", CommandType.General, delegate (CommandArgs args) + { + args.Editor.Mode = EditorMode.ObjectPlacement; + }); + AddCommand("ResetCamera", "Reset camera position", CommandType.View, delegate (CommandArgs args) { args.Editor.ResetCamera(); @@ -819,7 +824,8 @@ static CommandHandler() } } - EditorActions.DeleteRooms(args.Editor.SelectedRooms, args.Window); + if (args.Editor.Mode == EditorMode.Map2D) + EditorActions.DeleteRooms(args.Editor.SelectedRooms, args.Window); }); AddCommand("DeleteMissingObjects", "Delete missing objects", CommandType.Edit, delegate (CommandArgs args) @@ -1174,7 +1180,7 @@ static CommandHandler() AddCommand("AddImportedGeometry", "Add imported geometry", CommandType.Objects, delegate (CommandArgs args) { - args.Editor.Action = new EditorActionPlace(false, (l, r) => new ImportedGeometryInstance() { Model = args.Editor.ChosenImportedGeometry }); + args.Editor.Action = new EditorActionPlace(false, (l, r) => new ImportedGeometryInstance() { Model = args.Editor.ChosenItems.OfType().FirstOrDefault() }); }); AddCommand("AddBoxVolumeInSelectedArea", "Add box volume in selected area", CommandType.Objects, delegate (CommandArgs args) @@ -1679,6 +1685,7 @@ static CommandHandler() AddCommand("ShowRoomOptions", "Show room options", CommandType.Windows, (CommandArgs args) => args.Editor.ToggleToolWindow(typeof(RoomOptions))); AddCommand("ShowItemBrowser", "Show item browser", CommandType.Windows, (CommandArgs args) => args.Editor.ToggleToolWindow(typeof(ItemBrowser))); AddCommand("ShowImportedGeometryBrowser", "Show imported geometry browser", CommandType.Windows, (CommandArgs args) => args.Editor.ToggleToolWindow(typeof(ImportedGeometryBrowser))); + AddCommand("ShowContentBrowser", "Show content browser", CommandType.Windows, (CommandArgs args) => args.Editor.ToggleToolWindow(typeof(ContentBrowser))); AddCommand("ShowSectorOptions", "Show sector options", CommandType.Windows, (CommandArgs args) => args.Editor.ToggleToolWindow(typeof(SectorOptions))); AddCommand("ShowLighting", "Show lighting", CommandType.Windows, (CommandArgs args) => args.Editor.ToggleToolWindow(typeof(Lighting))); AddCommand("ShowPalette", "Show palette", CommandType.Windows, (CommandArgs args) => args.Editor.ToggleToolWindow(typeof(Palette))); @@ -1965,7 +1972,7 @@ static CommandHandler() { if (!EditorActions.CheckForRoomAndSectorSelection(args.Window)) return; - if (!EditorActions.VersionCheck(args.Editor.Level.Settings.GameVersion.Native() >= TRVersion.Game.TR3, "Monkeyswing")) + if (!EditorActions.VersionCheck(args.Editor.Level.Settings.GameVersion.SupportsMonkeySwing(), "Monkeyswing")) return; EditorActions.ToggleSectorFlag(args.Editor.SelectedRoom, args.Editor.SelectedSectors.Area, SectorFlags.Monkey); }); diff --git a/TombEditor/Configuration.cs b/TombEditor/Configuration.cs index f967c6d582..55292a5842 100644 --- a/TombEditor/Configuration.cs +++ b/TombEditor/Configuration.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.Drawing; using System.Numerics; -using System.Reflection; using TombLib; using TombLib.LevelData; using TombLib.LevelData.VisualScripting; @@ -45,6 +44,10 @@ public class Configuration : ConfigurationBase public bool RenderingItem_ShowMultipleWadsPrompt { get; set; } = true; public bool RenderingItem_Animate { get; set; } = false; + // Content Browser options + + public float ContentBrowser_TileWidth { get; set; } = 88; + // Main 3D window options public int Rendering3D_DrawRoomsMaxDepth { get; set; } = 6; @@ -59,7 +62,8 @@ public class Configuration : ConfigurationBase public float Rendering3D_LineWidth { get; set; } = 10.0f; public float Rendering3D_FieldOfView { get; set; } = 50.0f; public bool Rendering3D_ToolboxVisible { get; set; } = true; - public Point Rendering3D_ToolboxPosition { get; set; } = new Point(15, 45); + public Point Rendering3D_ToolboxPosition { get; set; } = new Point(15, 15); + public Point Rendering3D_ObjectBrushToolboxPosition { get; set; } = new Point(50, 15); public bool Rendering3D_DisablePickingForImportedGeometry { get; set; } = false; public bool Rendering3D_DisablePickingForHiddenRooms { get; set; } = false; public bool Rendering3D_ShowPortals { get; set; } = false; @@ -155,6 +159,23 @@ public class Configuration : ConfigurationBase public float Gizmo_ScaleCubeSize { get; set; } = 128.0f; public float Gizmo_LineThickness { get; set; } = 45.0f; + // Object brush options + + public ObjectBrushShape ObjectBrush_Shape { get; set; } = ObjectBrushShape.Circle; + public float ObjectBrush_Radius { get; set; } = 512.0f; + public float ObjectBrush_Density { get; set; } = 1.0f; + public float ObjectBrush_Rotation { get; set; } = 0.0f; + public float ObjectBrush_ScaleMin { get; set; } = 0.8f; + public float ObjectBrush_ScaleMax { get; set; } = 1.2f; + public bool ObjectBrush_RandomizeRotation { get; set; } = true; + public bool ObjectBrush_FollowMouseDirection { get; set; } = false; + public bool ObjectBrush_Orthogonal { get; set; } = false; + public bool ObjectBrush_RandomizeScale { get; set; } = false; + public bool ObjectBrush_PlaceInAdjacentRooms { get; set; } = false; + public bool ObjectBrush_FitToGround { get; set; } = true; + public bool ObjectBrush_AlignToGrid { get; set; } = false; + public bool ObjectBrush_ShowTextures { get; set; } = true; + // Autosave options public bool AutoSave_Enable { get; set; } = true; @@ -186,7 +207,7 @@ public class Configuration : ConfigurationBase public string[] UI_ToolbarButtons { get; set; } = new string[] { - "2D", "3D", "FaceEdit", "LightingMode", "DrawWhiteLighting", "|", + "2D", "3D", "FaceEdit", "ObjectPlacement", "LightingMode", "DrawWhiteLighting", "|", "Undo", "Redo", "|", "CenterCamera", "ToggleFlyMode", "|", "DrawPortals", "DrawAllRooms", "DrawHorizon", @@ -204,6 +225,7 @@ public class Configuration : ConfigurationBase public EditorTool UI_LastGeometryTool { get; set; } = new EditorTool(); public EditorTool UI_LastTexturingTool { get; set; } = new EditorTool() { Tool = EditorToolType.Brush }; + public EditorTool UI_LastObjectPlacementTool { get; set; } = new EditorTool() { Tool = EditorToolType.Brush }; // Geometry IO Window @@ -384,8 +406,8 @@ public void EnsureDefaults() }, new DockGroupState { - Contents = new List { "Palette" }, - VisibleContent = "Palette", + Contents = new List { "ContentBrowser", "Palette" }, + VisibleContent = "ContentBrowser", Order = 1, Size = new Size(645,141) } diff --git a/TombEditor/Controls/ObjectBrush/ObjectBrushActions.cs b/TombEditor/Controls/ObjectBrush/ObjectBrushActions.cs new file mode 100644 index 0000000000..b2ae942178 --- /dev/null +++ b/TombEditor/Controls/ObjectBrush/ObjectBrushActions.cs @@ -0,0 +1,142 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using TombLib; +using TombLib.LevelData; +using TombLib.Utils; + +namespace TombEditor.Controls.ObjectBrush +{ + public static class Actions + { + private static int _pencilItemIndex; + + private static Vector3 AlignWorldPosToGrid(Vector3 pos) + { + float half = Level.SectorSizeUnit * 0.5f; + return new Vector3( + (float)Math.Floor(pos.X / Level.SectorSizeUnit) * Level.SectorSizeUnit + half, + pos.Y, + (float)Math.Floor(pos.Z / Level.SectorSizeUnit) * Level.SectorSizeUnit + half); + } + + // Stroke-session state: lives from BeginBrushStroke to EndBrushStroke. + private static readonly List _strokeUndoList = new List(); + private static readonly List _strokePlacedObjects = new List(); + private static readonly HashSet _strokeProcessedObjects = new HashSet(); + + // Direction angle for FollowMouseDirection mode. Set by Panel3D before each stroke call. + internal static float? MouseDirectionAngle; + + private static void ExecuteBrushAction(Editor editor, Room room, float localX, float localZ) + { + if (editor.ChosenItems.Count == 0) + return; + + var sectorConstraint = editor.SelectedSectors.Valid && !editor.SelectedSectors.Empty ? (RectangleInt2?)editor.SelectedSectors.Area : null; + + switch (editor.Tool.Tool) + { + case EditorToolType.Brush: + var placed = Helper.PlaceObjectsWithBrush(editor, room, localX, localZ, sectorConstraint); + _strokeUndoList.AddRange(placed.Select(o => new AddRemoveObjectUndoInstance(editor.UndoManager, o, true))); + _strokePlacedObjects.AddRange(placed); + break; + + case EditorToolType.Eraser: + var removed = Helper.EraseObjectsWithBrush(editor, room, localX, localZ, sectorConstraint); + _strokeUndoList.AddRange(removed.Select(r => new AddRemoveObjectUndoInstance(editor.UndoManager, r.Obj, false, r.Room))); + break; + + case EditorToolType.ObjectSelection: + Helper.SelectObjectsWithBrush(editor, room, localX, localZ, sectorConstraint, _strokeProcessedObjects); + break; + + case EditorToolType.ObjectDeselection: + Helper.SelectObjectsWithBrush(editor, room, localX, localZ, sectorConstraint, _strokeProcessedObjects, deselect: true); + break; + + case EditorToolType.Pencil: + case EditorToolType.Line: + var pencilPlaced = Helper.PlaceObjectWithPencil(editor, room, localX, localZ, ref _pencilItemIndex, + sectorConstraint, skipOverlapCheck: editor.Tool.Tool == EditorToolType.Line); + _strokeUndoList.AddRange(pencilPlaced.Select(o => new AddRemoveObjectUndoInstance(editor.UndoManager, o, true))); + _strokePlacedObjects.AddRange(pencilPlaced); + break; + } + } + + public static Vector3 BeginBrushStroke(Editor editor, Room room, Vector3 cursorWorldPos) + { + _pencilItemIndex = 0; + _strokeUndoList.Clear(); + _strokePlacedObjects.Clear(); + _strokeProcessedObjects.Clear(); + + if (editor.Configuration.ObjectBrush_AlignToGrid && (editor.Tool.Tool == EditorToolType.Pencil || editor.Tool.Tool == EditorToolType.Line)) + cursorWorldPos = AlignWorldPosToGrid(cursorWorldPos); + + float localX = cursorWorldPos.X - room.WorldPos.X; + float localZ = cursorWorldPos.Z - room.WorldPos.Z; + + ExecuteBrushAction(editor, room, localX, localZ); + return cursorWorldPos; + } + + // Returns true if a paint action was performed (moved far enough from last position). + + public static bool ContinueBrushStroke(Editor editor, Room room, Vector3 cursorWorldPos, + Vector3? lastWorldPosition, float quantizationDistance) + { + if (editor.Configuration.ObjectBrush_AlignToGrid && editor.Tool.Tool == EditorToolType.Pencil) + cursorWorldPos = AlignWorldPosToGrid(cursorWorldPos); + + if (lastWorldPosition.HasValue) + { + float dx = cursorWorldPos.X - lastWorldPosition.Value.X; + float dz = cursorWorldPos.Z - lastWorldPosition.Value.Z; + + if (dx * dx + dz * dz < quantizationDistance * quantizationDistance) + return false; + } + + if (editor.ChosenItems.Count == 0) + return false; + + float localX = cursorWorldPos.X - room.WorldPos.X; + float localZ = cursorWorldPos.Z - room.WorldPos.Z; + + ExecuteBrushAction(editor, room, localX, localZ); + return true; + } + + public static void ExecuteFill(Editor editor, Room selectedRoom) + { + if (editor.ChosenItems.Count == 0) + return; + + var sectorConstraint = editor.SelectedSectors.Valid && !editor.SelectedSectors.Empty + ? (RectangleInt2?)editor.SelectedSectors.Area + : (RectangleInt2?)new RectangleInt2(1, 1, selectedRoom.NumXSectors - 2, selectedRoom.NumZSectors - 2); + + var placed = Helper.FillAreaWithObjects(editor, selectedRoom, editor.ChosenItems, sectorConstraint.Value); + if (placed.Count == 0) + return; + + EditorActions.AllocateScriptIds(placed); + editor.UndoManager.Push(placed.Select(o => (UndoRedoInstance)new AddRemoveObjectUndoInstance(editor.UndoManager, o, true)).ToList()); + } + + public static void EndBrushStroke(Editor editor) + { + EditorActions.AllocateScriptIds(_strokePlacedObjects); + + if (_strokeUndoList.Count > 0) + editor.UndoManager.Push(_strokeUndoList.ToList()); + + _strokeUndoList.Clear(); + _strokePlacedObjects.Clear(); + } + } +} diff --git a/TombEditor/Controls/ObjectBrush/ObjectBrushConstants.cs b/TombEditor/Controls/ObjectBrush/ObjectBrushConstants.cs new file mode 100644 index 0000000000..5ddc20a1d3 --- /dev/null +++ b/TombEditor/Controls/ObjectBrush/ObjectBrushConstants.cs @@ -0,0 +1,16 @@ +using TombLib.LevelData; + +namespace TombEditor.Controls.ObjectBrush +{ + internal class Constants + { + public const float RadiusAdjustmentStep = 64.0f; + public const float AngleAdjustmentStep = 5.0f; + public const float ParamDeadzone = 30; + + public const float MinRadius = 0.1f; + public const float MaxRadius = 20.0f; + public const float MinDensity = 0.01f; + public const float MaxDensity = 10.0f; + } +} diff --git a/TombEditor/Controls/ObjectBrush/ObjectBrushHelper.cs b/TombEditor/Controls/ObjectBrush/ObjectBrushHelper.cs new file mode 100644 index 0000000000..a206c45d06 --- /dev/null +++ b/TombEditor/Controls/ObjectBrush/ObjectBrushHelper.cs @@ -0,0 +1,984 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using System.Windows.Forms; +using TombLib; +using TombLib.LevelData; +using TombLib.Wad; + +namespace TombEditor.Controls.ObjectBrush +{ + public static class Helper + { + private static readonly Random _rng = new Random(); + + #region Floor Height and Geometry Queries + + public static float? GetFloorHeightAtPoint(Room room, float localX, float localZ) + { + float sectorX = localX / Level.SectorSizeUnit; + float sectorZ = localZ / Level.SectorSizeUnit; + + int ix = (int)sectorX; + int iz = (int)sectorZ; + + var sector = room.GetSectorTry(new VectorInt2(ix, iz)); + if (sector == null || sector.IsAnyWall) + return null; + + float fx = sectorX - ix; + float fz = sectorZ - iz; + + float h00 = sector.Floor.XnZn; + float h10 = sector.Floor.XpZn; + float h01 = sector.Floor.XnZp; + float h11 = sector.Floor.XpZp; + + float height = h00 * (1 - fx) * (1 - fz) + + h10 * fx * (1 - fz) + + h01 * (1 - fx) * fz + + h11 * fx * fz; + + return height; + } + + public static bool IsValidFloorPosition(Room room, int sectorX, int sectorZ) + { + if (sectorX < 1 || sectorZ < 1 || sectorX >= room.NumXSectors - 1 || sectorZ >= room.NumZSectors - 1) + return false; + + var sector = room.GetSectorTry(new VectorInt2(sectorX, sectorZ)); + return sector != null && !sector.IsAnyWall; + } + + public static bool IsWithinConstraint(int sectorX, int sectorZ, RectangleInt2? constraint) + { + if (!constraint.HasValue) + return true; + + var c = constraint.Value; + return sectorX >= c.X0 && sectorX <= c.X1 && sectorZ >= c.Y0 && sectorZ <= c.Y1; + } + + public static (Room Room, float LocalX, float LocalZ) ResolveFloorRoom(Room room, float localX, float localZ) + { + int sectorX = (int)(localX / Level.SectorSizeUnit); + int sectorZ = (int)(localZ / Level.SectorSizeUnit); + + var sector = room.GetSectorTry(new VectorInt2(sectorX, sectorZ)); + + if (sector?.FloorPortal != null) + { + // Only probe through solid collisional portals. + var connInfo = room.GetFloorRoomConnectionInfo(new VectorInt2(sectorX, sectorZ), true); + if (!sector.FloorPortal.IsTraversable || connInfo.TraversableType != Room.RoomConnectionType.FullPortal) + return (room, localX, localZ); + + var bottomRoom = sector.FloorPortal.AdjoiningRoom; + if (bottomRoom != null) + { + float bottomLocalX = localX + (room.WorldPos.X - bottomRoom.WorldPos.X); + float bottomLocalZ = localZ + (room.WorldPos.Z - bottomRoom.WorldPos.Z); + + int bsx = (int)(bottomLocalX / Level.SectorSizeUnit); + int bsz = (int)(bottomLocalZ / Level.SectorSizeUnit); + + if (IsValidFloorPosition(bottomRoom, bsx, bsz)) + return (bottomRoom, bottomLocalX, bottomLocalZ); + } + } + + return (room, localX, localZ); + } + + // Check if a position sits on a vertical non-solid non-collisional portal. + public static bool IsOnNonSolidPortal(Room room, float localX, float localZ) + { + int sectorX = (int)(localX / Level.SectorSizeUnit); + int sectorZ = (int)(localZ / Level.SectorSizeUnit); + + var sector = room.GetSectorTry(new VectorInt2(sectorX, sectorZ)); + if (sector?.FloorPortal == null) + return false; + + var connInfo = room.GetFloorRoomConnectionInfo(new VectorInt2(sectorX, sectorZ), true); + return connInfo.TraversableType != Room.RoomConnectionType.NoPortal && + connInfo.TraversableType != Room.RoomConnectionType.FullPortal; + } + + #endregion + + #region Bounding Box and Placement Safety + + public static BoundingBox? GetItemBoundingBox(Level level, IWadObject wadObject) + { + if (wadObject is WadStatic wadStatic) + { + if (wadStatic.Mesh?.BoundingBox.Size.Length() > 0.0f) + return wadStatic.CollisionBox; + } + else if (wadObject is WadMoveable wadMoveable) + { + if (wadMoveable.Animations?.Count > 0 && wadMoveable.Animations[0].KeyFrames.Count > 0) + return wadMoveable.Animations[0].KeyFrames[0].BoundingBox; + } + else if (wadObject is ImportedGeometry geo) + { + if (geo.DirectXModel != null) + { + // HACK: Invert Y axis of the bounding box. + var result = geo.DirectXModel.BoundingBox; + var min = result.Maximum.Y; + result.Maximum.Y = result.Minimum.Y; + result.Minimum.Y = min; + + return result; + } + } + + return null; + } + + // Checks if a room object instance matches one of the chosen IWadObject entries. + private static bool MatchesChosen(ObjectInstance obj, IReadOnlyList chosenItems) + { + if (obj is not PositionBasedObjectInstance pObj) + return false; + + if (obj is ItemInstance item) + { + foreach (var chosen in chosenItems) + { + if (chosen is WadMoveable m && !item.ItemType.IsStatic && item.ItemType.MoveableId == m.Id) + return true; + if (chosen is WadStatic s && item.ItemType.IsStatic && item.ItemType.StaticId == s.Id) + return true; + } + } + else if (obj is ImportedGeometryInstance geoInst) + { + foreach (var chosen in chosenItems) + { + if (chosen is ImportedGeometry geo && geoInst.Model == geo) + return true; + } + } + + return false; + } + + // Checks bounding box corners at a given position and rotation for valid floor geometry. + // Returns corrected Y position or null if any corner is over invalid geometry. + + public static float? GetSafePlacementHeight(Room room, Vector3 localPos, float rotationYDeg, float scale, BoundingBox bbox) + { + // Allow 1 unit of leeway to avoid precision issues with corners sitting exactly on sector boundaries. + var scaledMin = new Vector2(bbox.Minimum.X * scale, bbox.Minimum.Z * scale) + Vector2.One; + var scaledMax = new Vector2(bbox.Maximum.X * scale, bbox.Maximum.Z * scale) - Vector2.One; + + var corners = new Vector2[] + { + new Vector2(scaledMin.X, scaledMin.Y), + new Vector2(scaledMax.X, scaledMin.Y), + new Vector2(scaledMax.X, scaledMax.Y), + new Vector2(scaledMin.X, scaledMax.Y) + }; + + float rotRad = rotationYDeg * (float)(Math.PI / 180.0); + float cosR = (float)Math.Cos(rotRad); + float sinR = (float)Math.Sin(rotRad); + + float minFloorHeight = float.MaxValue; + + // Place the object so the bottom of the bounding box rests on the lowest floor point. + foreach (var corner in corners) + { + float rx = corner.X * cosR + corner.Y * sinR; + float rz = -corner.X * sinR + corner.Y * cosR; + + float? floorH = GetFloorHeightAtPoint(room, localPos.X + rx, localPos.Z + rz); + if (!floorH.HasValue) + return null; + + minFloorHeight = Math.Min(minFloorHeight, floorH.Value); + } + + return minFloorHeight - bbox.Maximum.Y * scale; + } + + #endregion + + #region Brush Area Queries + + public static List GetBrushRooms(Room currentRoom, bool includeAdjacent) + { + var rooms = new List { currentRoom }; + if (includeAdjacent) + { + foreach (var portal in currentRoom.Portals) + { + if (portal.AdjoiningRoom == null || rooms.Contains(portal.AdjoiningRoom)) + continue; + + // Skip ceiling portals (room above) and non-traversable floor portals + // to match the traversability logic applied in ResolveFloorRoom. + if (portal.Direction == PortalDirection.Ceiling) + continue; + if (portal.Direction == PortalDirection.Floor && !portal.IsTraversable) + continue; + + rooms.Add(portal.AdjoiningRoom); + } + } + return rooms; + } + + public static bool IsInBrushArea(float x, float z, float centerX, float centerZ, float radius, ObjectBrushShape shape) + { + float dx = x - centerX; + float dz = z - centerZ; + + if (shape == ObjectBrushShape.Circle) + return (dx * dx + dz * dz) <= (radius * radius); + else + return Math.Abs(dx) <= radius && Math.Abs(dz) <= radius; + } + + // Checks if an object's oriented bounding box (OBB) intersects the brush area. + // Falls back to center-point test when no bounding box is available. + private static bool DoesBoundingBoxIntersectBrush(float objX, float objZ, float rotY, float scale, + BoundingBox? bbox, float centerX, float centerZ, float radius, ObjectBrushShape shape) + { + if (bbox == null || scale <= 0) + return IsInBrushArea(objX, objZ, centerX, centerZ, radius, shape); + + var bb = bbox.Value; + + float cx = (bb.Minimum.X + bb.Maximum.X) * 0.5f * scale; + float cz = (bb.Minimum.Z + bb.Maximum.Z) * 0.5f * scale; + float hw = (bb.Maximum.X - bb.Minimum.X) * 0.5f * scale; + float hd = (bb.Maximum.Z - bb.Minimum.Z) * 0.5f * scale; + + float r = rotY * (float)(Math.PI / 180.0); + float c = (float)Math.Cos(r), s = (float)Math.Sin(r); + + float ocx = objX + cx * c + cz * s; + float ocz = objZ - cx * s + cz * c; + + float dx = centerX - ocx, dz = centerZ - ocz; + float lx = dx * c - dz * s, lz = dx * s + dz * c; + + if (shape == ObjectBrushShape.Circle) + { + float nx = Math.Max(-hw, Math.Min(hw, lx)); + float nz = Math.Max(-hd, Math.Min(hd, lz)); + float dx2 = lx - nx, dz2 = lz - nz; + return dx2 * dx2 + dz2 * dz2 <= radius * radius; + } + + float aw = hw * Math.Abs(c) + hd * Math.Abs(s); + float ad = hw * Math.Abs(s) + hd * Math.Abs(c); + + return ocx - aw <= centerX + radius && ocx + aw >= centerX - radius && + ocz - ad <= centerZ + radius && ocz + ad >= centerZ - radius; + } + + private static int CountMatchingObjectsInArea(List positions, float centerWorldX, float centerWorldZ, float radius, ObjectBrushShape shape) + { + int count = 0; + + foreach (var pos in positions) + { + if (IsInBrushArea(pos.X, pos.Y, centerWorldX, centerWorldZ, radius, shape)) + count++; + } + + return count; + } + + public static int GetTargetObjectCount(float radius, float density, ObjectBrushShape shape) + { + float radiusSectors = radius / Level.SectorSizeUnit; + float area = shape == ObjectBrushShape.Circle ? (float)(Math.PI * radiusSectors * radiusSectors) : (2 * radiusSectors) * (2 * radiusSectors); + return Math.Max(1, (int)Math.Round(area * density)); + } + + #endregion + + #region Object Creation and Candidate Position Generation + + public static PositionBasedObjectInstance CreateObjectInstance(IWadObject wadObject) + { + if (wadObject is WadStatic s) + return new StaticInstance { WadObjectId = s.Id }; + else if (wadObject is WadMoveable m) + return new MoveableInstance { WadObjectId = m.Id }; + else if (wadObject is ImportedGeometry geo) + return new ImportedGeometryInstance { Model = geo }; + + return null; + } + + public static List GenerateCandidatePositions(float centerWorldX, float centerWorldZ, float radius, float density, ObjectBrushShape shape) + { + var result = new List(); + + if (density <= 0) + return result; + + float cellSizeWorld = Level.SectorSizeUnit / (float)Math.Sqrt(density); + + int gridMinX = (int)Math.Floor((centerWorldX - radius) / cellSizeWorld); + int gridMaxX = (int)Math.Ceiling((centerWorldX + radius) / cellSizeWorld); + int gridMinZ = (int)Math.Floor((centerWorldZ - radius) / cellSizeWorld); + int gridMaxZ = (int)Math.Ceiling((centerWorldZ + radius) / cellSizeWorld); + + for (int gx = gridMinX; gx <= gridMaxX; gx++) + { + for (int gz = gridMinZ; gz <= gridMaxZ; gz++) + { + float px = (gx + (float)_rng.NextDouble()) * cellSizeWorld; + float pz = (gz + (float)_rng.NextDouble()) * cellSizeWorld; + + if (IsInBrushArea(px, pz, centerWorldX, centerWorldZ, radius, shape)) + result.Add(new Vector2(px, pz)); + } + } + + return result; + } + + #endregion + + #region Distance and Overlap Checks + + private static bool IsTooCloseInList(List positions, float x, float z, float minDistSq) + { + foreach (var pos in positions) + { + float dx = pos.X - x; + float dz = pos.Y - z; + if (dx * dx + dz * dz < minDistSq) + return true; + } + + return false; + } + + #endregion + + #region Main Brush Operations + + private struct PlacementContext + { + public Dictionary BoundsCache; + public Dictionary> PosCache; + } + + // Main object placement operation. + + public static List PlaceObjectsWithBrush(Editor editor, Room room, float x, float z, + RectangleInt2? sectorConstraint = null) + { + var shape = editor.Configuration.ObjectBrush_Shape; + float radius = editor.Configuration.ObjectBrush_Radius; + float density = editor.Configuration.ObjectBrush_Density; + + var placedObjects = new List(); + + // Min distance for spacing (derived from density, in world units). + float cellSizeWorld = Level.SectorSizeUnit / (float)Math.Sqrt(Math.Max(0.01f, density)); + float minDistWorld = cellSizeWorld * 0.5f; + float minDistSq = minDistWorld * minDistWorld; + + var rooms = GetBrushRooms(room, editor.Configuration.ObjectBrush_PlaceInAdjacentRooms); + int targetCount = GetTargetObjectCount(radius, density, shape); + + // Build per-call context: bounds cache (shared across rooms) + position cache (per room). + var context = new PlacementContext + { + BoundsCache = new Dictionary(), + PosCache = new Dictionary>() + }; + + foreach (var r in rooms) + { + var posList = new List(); + + foreach (var obj in r.Objects) + if (obj is PositionBasedObjectInstance pObj && MatchesChosen(pObj, editor.ChosenItems)) + posList.Add(new Vector2(obj.Position.X, obj.Position.Z)); + + context.PosCache[r] = posList; + } + + foreach (var targetRoom in rooms) + { + var offset = room.WorldPos - targetRoom.WorldPos; + float localCenterX = x + offset.X; + float localCenterZ = z + offset.Z; + + int existingCount = CountMatchingObjectsInArea(context.PosCache[targetRoom], localCenterX, localCenterZ, radius, shape); + if (existingCount >= targetCount) + continue; + + int toPlace = targetCount - existingCount; + + var candidates = GenerateCandidatePositions(localCenterX, localCenterZ, radius, density, shape); + ShuffleList(candidates); + + int placed = 0; + + foreach (var candidate in candidates) + { + if (placed >= toPlace) + break; + + var chosenItem = editor.ChosenItems[_rng.Next(editor.ChosenItems.Count)]; + + if (!TryPlaceObject(editor, editor.Level, targetRoom, candidate, chosenItem, + minDistSq, ref context, sectorConstraint, out var instance)) + continue; + + placedObjects.Add(instance); + placed++; + } + } + + editor.ObjectChange(placedObjects, ObjectChangeType.Add); + return placedObjects; + } + + // Individual object placement attempt. + + private static bool TryPlaceObject(Editor editor, Level level, Room targetRoom, + Vector2 candidate, IWadObject chosenItem, float minDistSq, ref PlacementContext context, RectangleInt2? sectorConstraint, + out PositionBasedObjectInstance instance) + { + var config = editor.Configuration; + + instance = null; + float worldX = candidate.X; + float worldZ = candidate.Y; + + int sectorX = (int)(worldX / Level.SectorSizeUnit); + int sectorZ = (int)(worldZ / Level.SectorSizeUnit); + + if (!IsValidFloorPosition(targetRoom, sectorX, sectorZ)) + return false; + + if (!IsWithinConstraint(sectorX, sectorZ, sectorConstraint)) + return false; + + var placementRoom = targetRoom; + float placeX = worldX; + float placeZ = worldZ; + + // Probe through floor portals to find the actual room and position. + if (config.ObjectBrush_PlaceInAdjacentRooms) + { + var resolved = ResolveFloorRoom(targetRoom, worldX, worldZ); + placementRoom = resolved.Room; + placeX = resolved.LocalX; + placeZ = resolved.LocalZ; + } + else + { + // Skip positions over traversable floor portals (would fall into room below) + // and over non-solid partial portals with ambiguous geometry. + var resolved = ResolveFloorRoom(targetRoom, worldX, worldZ); + if (resolved.Room != targetRoom || IsOnNonSolidPortal(targetRoom, worldX, worldZ)) + return false; + } + + // Use cached position list instead of scanning room.Objects on every candidate + if (!context.PosCache.TryGetValue(placementRoom, out var posList)) + { + posList = new List(); + context.PosCache[placementRoom] = posList; + } + + if (IsTooCloseInList(posList, placeX, placeZ, minDistSq)) + return false; + + bool randomRot = config.ObjectBrush_RandomizeRotation && editor.Tool.Tool != EditorToolType.Line; + + float rotY; + if (config.ObjectBrush_FollowMouseDirection) + { + rotY = Actions.MouseDirectionAngle ?? config.ObjectBrush_Rotation; + if (randomRot) + rotY += ((float)_rng.NextDouble() - 0.5f) * 45.0f; + + rotY = ((rotY % 360.0f) + 360.0f) % 360.0f; + } + else if (randomRot) + { + rotY = (float)(_rng.NextDouble() * 360.0f); + } + else + { + rotY = config.ObjectBrush_Rotation; + } + + if (config.ObjectBrush_Orthogonal) + rotY = (rotY + 90.0f) % 360.0f; + + float scale = 1.0f; + + if (config.ObjectBrush_RandomizeScale && chosenItem is WadStatic or ImportedGeometry) + scale = config.ObjectBrush_ScaleMin + (float)_rng.NextDouble() * (config.ObjectBrush_ScaleMax - config.ObjectBrush_ScaleMin); + + // Determine Y position in the placement room (use cached bounds to avoid repeated wad look-ups). + if (!context.BoundsCache.TryGetValue(chosenItem, out var bbox)) + { + bbox = GetItemBoundingBox(level, chosenItem); + context.BoundsCache[chosenItem] = bbox; + } + + float yPos; + + if (config.ObjectBrush_FitToGround && bbox.HasValue) + { + float? safeHeight = GetSafePlacementHeight(placementRoom, new Vector3(placeX, 0, placeZ), rotY, scale, bbox.Value); + if (!safeHeight.HasValue) + return false; + + yPos = safeHeight.Value; + } + else + { + float? floorH = GetFloorHeightAtPoint(placementRoom, placeX, placeZ); + if (!floorH.HasValue) + return false; + + yPos = floorH.Value; + } + + instance = CreateObjectInstance(chosenItem); + instance.Position = new Vector3(placeX, yPos, placeZ); + + if (instance is IRotateableY rotatable) + rotatable.RotationY = rotY; + + if (instance is IScaleable scaleable && config.ObjectBrush_RandomizeScale) + scaleable.Scale = scale; + + placementRoom.AddObject(level, instance); + EditorActions.RebuildLightsForObject(instance); + + // Record newly placed position in the cache for subsequent distance checks within this stroke. + context.PosCache[placementRoom].Add(new Vector2(placeX, placeZ)); + + return true; + } + + // Main object erase operation. + + public static List<(PositionBasedObjectInstance Obj, Room Room)> EraseObjectsWithBrush(Editor editor, Room room, + float centerWorldX, float centerWorldZ, RectangleInt2? sectorConstraint = null) + { + var shape = editor.Configuration.ObjectBrush_Shape; + float radius = editor.Configuration.ObjectBrush_Radius; + float density = editor.Configuration.ObjectBrush_Density; + bool adjacent = editor.Configuration.ObjectBrush_PlaceInAdjacentRooms; + bool filterByChosen = Control.ModifierKeys.HasFlag(Keys.Shift); + + int targetCount = GetTargetObjectCount(radius, density * 0.3f, shape); + + var removedObjects = new List<(PositionBasedObjectInstance, Room)>(); + var rooms = GetBrushRooms(room, adjacent); + int totalRemoved = 0; + + foreach (var targetRoom in rooms) + { + if (totalRemoved >= targetCount) + break; + + var offset = room.WorldPos - targetRoom.WorldPos; + float localCenterX = centerWorldX + offset.X; + float localCenterZ = centerWorldZ + offset.Z; + + var toRemove = CollectObjectsInBrushArea(targetRoom, localCenterX, localCenterZ, radius, shape, filterByChosen ? editor.ChosenItems : null, sectorConstraint); + + // Shuffle, so removal is spatially random rather than biased by iteration order. + ShuffleList(toRemove); + + foreach (var obj in toRemove) + { + if (totalRemoved >= targetCount) + break; + + if (editor.SelectedObject == obj) + editor.SelectedObject = null; + + // Capture room reference before removal (RemoveObject sets obj.Room to null). + targetRoom.RemoveObject(editor.Level, obj); + removedObjects.Add((obj, targetRoom)); + totalRemoved++; + } + } + + foreach (var (obj, objRoom) in removedObjects) + editor.ObjectChange(obj, ObjectChangeType.Remove, objRoom); + + return removedObjects; + } + + private static List CollectObjectsInBrushArea(Room room, float centerWorldX, float centerWorldZ, float radius, + ObjectBrushShape shape, IReadOnlyList chosenItems, RectangleInt2? sectorConstraint) + { + var result = new List(); + + foreach (var obj in room.Objects) + { + if (!(obj is ItemInstance || obj is ImportedGeometryInstance)) + continue; + + if (chosenItems != null && !MatchesChosen(obj, chosenItems)) + continue; + + if (sectorConstraint.HasValue) + { + int sx = (int)(obj.Position.X / Level.SectorSizeUnit); + int sz = (int)(obj.Position.Z / Level.SectorSizeUnit); + if (!IsWithinConstraint(sx, sz, sectorConstraint)) + continue; + } + + float rotY = (obj as IRotateableY)?.RotationY ?? 0.0f; + float scale = (obj as IScaleable)?.Scale ?? 1.0f; + + var bbox = GetBoundingBox(obj); + if (DoesBoundingBoxIntersectBrush(obj.Position.X, obj.Position.Z, rotY, scale, bbox, centerWorldX, centerWorldZ, radius, shape)) + result.Add(obj as PositionBasedObjectInstance); + } + return result; + } + + private static BoundingBox? GetBoundingBox(ObjectInstance obj) + { + BoundingBox? bbox = null; + + if (obj is ItemInstance item) + { + bbox = GetItemBoundingBox(obj.Room.Level, item.ItemType.IsStatic + ? (IWadObject)obj.Room.Level.Settings?.WadTryGetStatic(item.ItemType.StaticId) + : (IWadObject)obj.Room.Level.Settings?.WadTryGetMoveable(item.ItemType.MoveableId)); + } + else if (obj is ImportedGeometryInstance geoInst && geoInst.Model != null) + bbox = GetItemBoundingBox(obj.Room.Level, geoInst.Model); + + return bbox; + } + + public static void ShuffleList(List list) + { + for (int i = list.Count - 1; i > 0; i--) + { + int j = _rng.Next(i + 1); + (list[i], list[j]) = (list[j], list[i]); + } + } + + #endregion + + #region Selection Tool + + // Selects or deselects objects within brush radius. + // Ctrl deselects, Shift limits to current ChosenItems only. + + public static void SelectObjectsWithBrush(Editor editor, Room room, float centerWorldX, float centerWorldZ, + RectangleInt2? sectorConstraint, HashSet processedObjects = null, bool deselect = false) + { + var shape = editor.Configuration.ObjectBrush_Shape; + float radius = editor.Configuration.ObjectBrush_Radius; + bool adjacent = editor.Configuration.ObjectBrush_PlaceInAdjacentRooms; + bool filter = Control.ModifierKeys.HasFlag(Keys.Shift); + + var rooms = GetBrushRooms(room, adjacent); + var objectsInArea = new List(); + + foreach (var targetRoom in rooms) + { + var offset = room.WorldPos - targetRoom.WorldPos; + float localCenterX = centerWorldX + offset.X; + float localCenterZ = centerWorldZ + offset.Z; + + foreach (var obj in targetRoom.Objects) + { + if (!(obj is ItemInstance || obj is ImportedGeometryInstance)) + continue; + + // Skip objects already processed during this stroke. + if (processedObjects != null && processedObjects.Contains(obj)) + continue; + + if (filter && !MatchesChosen(obj, editor.ChosenItems)) + continue; + + if (sectorConstraint.HasValue) + { + int sx = (int)(obj.Position.X / Level.SectorSizeUnit); + int sz = (int)(obj.Position.Z / Level.SectorSizeUnit); + if (!IsWithinConstraint(sx, sz, sectorConstraint)) + continue; + } + + float rotY = (obj as IRotateableY)?.RotationY ?? 0.0f; + float scale = (obj as IScaleable)?.Scale ?? 1.0f; + + var bbox = GetBoundingBox(obj); + if (DoesBoundingBoxIntersectBrush(obj.Position.X, obj.Position.Z, rotY, scale, bbox, localCenterX, localCenterZ, radius, shape)) + objectsInArea.Add(obj as PositionBasedObjectInstance); + } + } + + if (objectsInArea.Count == 0) + return; + + // Mark all newly found objects as processed for this stroke. + if (processedObjects != null) + { + foreach (var obj in objectsInArea) + processedObjects.Add(obj); + } + + if (deselect) + { + // Deselect all objects in the brush area from current selection. + if (editor.SelectedObject is ObjectGroup group) + { + var remaining = group.Where(o => !objectsInArea.Contains(o)).ToList(); + if (remaining.Count == 0) + editor.SelectedObject = null; + else if (remaining.Count == 1) + editor.SelectedObject = remaining[0]; + else + editor.SelectedObject = new ObjectGroup(remaining); + } + else if (objectsInArea.Any(o => o == editor.SelectedObject)) + { + editor.SelectedObject = null; + } + } + else + { + // Only select objects not already in the current selection. + foreach (var obj in objectsInArea) + { + bool alreadySelected = editor.SelectedObject == obj || + (editor.SelectedObject is ObjectGroup existing && existing.Contains(obj)); + if (!alreadySelected) + EditorActions.MultiSelect(obj); + } + } + } + + #endregion + + #region Pencil Tool + + // Computes spacing for seamless line placement along the rotation direction. + // Uses bounding box Z extent (local depth axis) of the first chosen item, or X if orthogonal. + + public static float ComputeLineSpacing(Editor editor) + { + if (editor.ChosenItems.Count == 0) + return editor.Configuration.ObjectBrush_Radius; + + var bbox = GetItemBoundingBox(editor.Level, editor.ChosenItems[0]); + if (!bbox.HasValue) + return editor.Configuration.ObjectBrush_Radius; + + float scale = editor.Configuration.ObjectBrush_RandomizeScale + ? (editor.Configuration.ObjectBrush_ScaleMin + editor.Configuration.ObjectBrush_ScaleMax) / 2.0f + : 1.0f; + + float extent = editor.Configuration.ObjectBrush_Orthogonal + ? (bbox.Value.Maximum.X - bbox.Value.Minimum.X) * scale + : (bbox.Value.Maximum.Z - bbox.Value.Minimum.Z) * scale; + + float radius = editor.Configuration.ObjectBrush_Radius; + return extent > 0.0f ? Math.Max(extent, radius) : radius; + } + + // Place a single object per step. Cycles through ChosenItems sequentially. + // ObjectBrush_Radius specifies fixed interval between placed objects. + // If Ctrl is held, advance only in the rotation direction. + + public static List PlaceObjectWithPencil(Editor editor, Room room, + float centerWorldX, float centerWorldZ, ref int itemIndex, RectangleInt2? sectorConstraint, bool skipOverlapCheck = false) + { + float radius = editor.Configuration.ObjectBrush_Radius; + bool adjacent = editor.Configuration.ObjectBrush_PlaceInAdjacentRooms; + bool orthogonal = editor.Configuration.ObjectBrush_Orthogonal; + + var placedObjects = new List(); + var level = editor.Level; + + // Build bbox cache. + var bboxCache = new Dictionary(); + + // Select the item to place. + var chosenItem = editor.ChosenItems[itemIndex % editor.ChosenItems.Count]; + + // Check if placement would overlap with any existing object of any chosen type. + if (!bboxCache.TryGetValue(chosenItem, out var bbox)) + { + bbox = GetItemBoundingBox(level, chosenItem); + bboxCache[chosenItem] = bbox; + } + + // Compute minimum distance from bounding box for seamless placement. + // Use X extent if orthogonal flag adds a 90-degree rotation to placed objects. + float minDist = radius; + if (bbox.HasValue) + { + float extent = orthogonal + ? bbox.Value.Maximum.X - bbox.Value.Minimum.X + : bbox.Value.Maximum.Z - bbox.Value.Minimum.Z; + + if (extent > 0.0f) + minDist = Math.Max(extent, radius); + } + float minDistSq = minDist * minDist; + + var rooms = GetBrushRooms(room, adjacent); + + foreach (var targetRoom in rooms) + { + // Check for overlap with existing objects, unless the caller opted out (e.g. Ctrl+pencil line mode). + bool tooClose = false; + var offset = room.WorldPos - targetRoom.WorldPos; + float localX = centerWorldX + offset.X; + float localZ = centerWorldZ + offset.Z; + + if (!skipOverlapCheck) + { + foreach (var obj in targetRoom.Objects) + { + if (obj is PositionBasedObjectInstance pObj && MatchesChosen(pObj, editor.ChosenItems)) + { + float dx = obj.Position.X - localX; + float dz = obj.Position.Z - localZ; + if (dx * dx + dz * dz < minDistSq) + { + tooClose = true; + break; + } + } + } + } + + if (tooClose) + continue; + + var context = new PlacementContext + { + BoundsCache = bboxCache, + PosCache = new Dictionary>() + }; + + var candidate = new Vector2(localX, localZ); + if (TryPlaceObject(editor, level, targetRoom, candidate, chosenItem, 0, ref context, sectorConstraint, out var instance)) + { + placedObjects.Add(instance); + itemIndex++; + break; + } + } + + editor.ObjectChange(placedObjects, ObjectChangeType.Add); + return placedObjects; + } + + #endregion + + #region Fill Tool + + // Fill a defined area with objects using density setting. + + public static List FillAreaWithObjects(Editor editor, Room room, + IReadOnlyList chosenItems, RectangleInt2 area) + { + float density = editor.Configuration.ObjectBrush_Density; + bool adjacent = editor.Configuration.ObjectBrush_PlaceInAdjacentRooms; + + var placedObjects = new List(); + var level = editor.Level; + + // Calculate center and extent of area in world units. + float areaMinX = area.X0 * Level.SectorSizeUnit; + float areaMinZ = area.Y0 * Level.SectorSizeUnit; + float areaMaxX = (area.X1 + 1) * Level.SectorSizeUnit; + float areaMaxZ = (area.Y1 + 1) * Level.SectorSizeUnit; + + // Generate candidate positions using density. + float cellSizeWorld = Level.SectorSizeUnit / (float)Math.Sqrt(Math.Max(0.01f, density)); + float minDistWorld = cellSizeWorld * 0.5f; + float minDistSq = minDistWorld * minDistWorld; + + var context = new PlacementContext + { + BoundsCache = new Dictionary(), + PosCache = new Dictionary>() + }; + + // Pre-populate position cache. + var rooms = GetBrushRooms(room, adjacent); + foreach (var r in rooms) + { + var posList = new List(); + foreach (var obj in r.Objects) + { + if (obj is PositionBasedObjectInstance pObj && MatchesChosen(pObj, chosenItems)) + posList.Add(new Vector2(obj.Position.X, obj.Position.Z)); + } + context.PosCache[r] = posList; + } + + // Generate candidates within the area. + int gridMinX = (int)Math.Floor(areaMinX / cellSizeWorld); + int gridMaxX = (int)Math.Ceiling(areaMaxX / cellSizeWorld); + int gridMinZ = (int)Math.Floor(areaMinZ / cellSizeWorld); + int gridMaxZ = (int)Math.Ceiling(areaMaxZ / cellSizeWorld); + + var candidates = new List(); + for (int gridX = gridMinX; gridX <= gridMaxX; gridX++) + { + for (int gridZ = gridMinZ; gridZ <= gridMaxZ; gridZ++) + { + float posX = (gridX + (float)_rng.NextDouble()) * cellSizeWorld; + float posZ = (gridZ + (float)_rng.NextDouble()) * cellSizeWorld; + + if (posX >= areaMinX && posX < areaMaxX && posZ >= areaMinZ && posZ < areaMaxZ) + candidates.Add(new Vector2(posX, posZ)); + } + } + + ShuffleList(candidates); + + foreach (var targetRoom in rooms) + { + var offset = room.WorldPos - targetRoom.WorldPos; + + foreach (var candidate in candidates) + { + var localCandidate = new Vector2(candidate.X + offset.X, candidate.Y + offset.Z); + var chosenItem = chosenItems[_rng.Next(chosenItems.Count)]; + + if (TryPlaceObject(editor, level, targetRoom, localCandidate, chosenItem, minDistSq, ref context, + (RectangleInt2?)area, out var instance)) + placedObjects.Add(instance); + } + } + + editor.ObjectChange(placedObjects, ObjectChangeType.Add); + return placedObjects; + } + + #endregion + } +} diff --git a/TombEditor/Controls/ObjectBrush/ObjectBrushToolbox.Designer.cs b/TombEditor/Controls/ObjectBrush/ObjectBrushToolbox.Designer.cs new file mode 100644 index 0000000000..3e92b7334c --- /dev/null +++ b/TombEditor/Controls/ObjectBrush/ObjectBrushToolbox.Designer.cs @@ -0,0 +1,46 @@ +namespace TombEditor.Controls.ObjectBrush +{ + partial class ObjectBrushToolbox + { + private System.ComponentModel.IContainer components = null; + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _toolboxView?.Cleanup(); + components?.Dispose(); + } + + base.Dispose(disposing); + } + + #region Component Designer generated code + + private void InitializeComponent() + { + _elementHost = new System.Windows.Forms.Integration.ElementHost(); + _toolboxView = new Views.ObjectBrushToolboxView(); + SuspendLayout(); + // + // _elementHost + // + _elementHost.Dock = System.Windows.Forms.DockStyle.Fill; + _elementHost.Name = "_elementHost"; + _elementHost.Child = _toolboxView; + // + // ObjectBrushToolbox + // + BackColor = System.Drawing.Color.FromArgb(60, 63, 65); + Controls.Add(_elementHost); + Name = "ObjectBrushToolbox"; + Size = new System.Drawing.Size(404, 94); + ResumeLayout(false); + } + + #endregion + + private System.Windows.Forms.Integration.ElementHost _elementHost; + private Views.ObjectBrushToolboxView _toolboxView; + } +} diff --git a/TombEditor/Controls/ObjectBrush/ObjectBrushToolbox.cs b/TombEditor/Controls/ObjectBrush/ObjectBrushToolbox.cs new file mode 100644 index 0000000000..e0448c2013 --- /dev/null +++ b/TombEditor/Controls/ObjectBrush/ObjectBrushToolbox.cs @@ -0,0 +1,46 @@ +using System; + +using DarkUI.Controls; + +namespace TombEditor.Controls.ObjectBrush +{ + public partial class ObjectBrushToolbox : DarkFloatingToolbox + { + public ObjectBrushToolbox() + { + VerticalGrip = false; + GripSize = 12; + AutoAnchor = true; + SnapToBorders = true; + DragAnyPoint = true; + + InitializeComponent(); + + _toolboxView.Loaded += OnToolboxViewLoaded; + } + + private void OnToolboxViewLoaded(object sender, System.Windows.RoutedEventArgs e) + { + _toolboxView.Loaded -= OnToolboxViewLoaded; + UpdateSizeFromContent(); + } + + // Measures WPF content and resizes the floating toolbox to fit. + + private void UpdateSizeFromContent() + { + _toolboxView.Measure(new System.Windows.Size( + double.PositiveInfinity, double.PositiveInfinity)); + + var desired = _toolboxView.DesiredSize; + float dpiScale = DeviceDpi / 96f; + + int contentWidth = (int)Math.Ceiling(desired.Width * dpiScale); + int contentHeight = (int)Math.Ceiling(desired.Height * dpiScale); + + Size = new System.Drawing.Size( + contentWidth + Padding.Left + Padding.Right, + contentHeight + Padding.Top + Padding.Bottom); + } + } +} diff --git a/TombEditor/Controls/ObjectBrush/ObjectBrushToolbox.resx b/TombEditor/Controls/ObjectBrush/ObjectBrushToolbox.resx new file mode 100644 index 0000000000..8b2ff64a11 --- /dev/null +++ b/TombEditor/Controls/ObjectBrush/ObjectBrushToolbox.resx @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + \ No newline at end of file diff --git a/TombEditor/Controls/Panel3D/MouseHandler/Panel3DMouse.cs b/TombEditor/Controls/Panel3D/MouseHandler/Panel3DMouse.cs index 16fb750056..fe793fb454 100644 --- a/TombEditor/Controls/Panel3D/MouseHandler/Panel3DMouse.cs +++ b/TombEditor/Controls/Panel3D/MouseHandler/Panel3DMouse.cs @@ -47,6 +47,8 @@ private void OnMouseButtonUp(MouseButtons button, Point location) _dragObjectMoved = false; _dragObjectPicked = false; + HandleBrushMouseUp(); + if (_gizmo.MouseUp()) Invalidate(); @@ -140,14 +142,16 @@ private void OnMouseDoubleClicked(MouseButtons button, Point location) } } - private void OnMouseWheelScroll(int delta) + private void OnMouseWheelScroll(int delta, Point location) { - if (!_movementTimer.Animating) - { - Console.WriteLine("Delta: " + delta); - Camera.Zoom(-delta * _editor.Configuration.Rendering3D_NavigationSpeedMouseWheelZoom); - Invalidate(); - } + if (_movementTimer.Animating) + return; + + if (HandleBrushWheelScroll(delta, location)) + return; + + Camera.Zoom(-delta * _editor.Configuration.Rendering3D_NavigationSpeedMouseWheelZoom); + Invalidate(); } private void OnMouseEntered() @@ -179,8 +183,12 @@ private void OnMouseDragAndDrop(DragEventArgs e) if (newSectorPicking.Room != _editor.SelectedRoom) _editor.SelectedRoom = newSectorPicking.Room; + // Check for a single IWadObject. var obj = e.Data.GetData(e.Data.GetFormats()[0]) as IWadObject; + // Check for multiple IWadObject[] (multi-selection drag from Content Browser). + var objArray = e.Data.GetData(e.Data.GetFormats()[0]) as IWadObject[]; + if (obj != null) { PositionBasedObjectInstance instance = null; @@ -196,6 +204,30 @@ private void OnMouseDragAndDrop(DragEventArgs e) if (instance != null) EditorActions.PlaceObject(_editor.SelectedRoom, newSectorPicking.Pos, instance); } + else if (objArray != null && objArray.Length > 0) + { + // Build a list of instances for all dragged wad objects + var instances = new List(); + foreach (var wadObj in objArray) + { + PositionBasedObjectInstance instance = null; + + if (wadObj is ImportedGeometry importedGeo) + instance = new ImportedGeometryInstance { Model = importedGeo }; + else if (wadObj is WadMoveable moveable) + instance = ItemInstance.FromItemType(new ItemType(moveable.Id, _editor?.Level?.Settings)); + else if (wadObj is WadStatic staticMesh) + instance = ItemInstance.FromItemType(new ItemType(staticMesh.Id, _editor?.Level?.Settings)); + + if (instance != null) + instances.Add(instance); + } + + if (instances.Count == 1) + EditorActions.PlaceObject(_editor.SelectedRoom, newSectorPicking.Pos, instances[0]); + else if (instances.Count > 1) + EditorActions.PlaceObject(_editor.SelectedRoom, newSectorPicking.Pos, new ObjectGroup(instances)); + } else if (filesToProcess != -1) { // Try to put custom geometry files, if any @@ -222,6 +254,8 @@ private void OnMouseDragEntered(DragEventArgs e) { if (e.Data.GetData(e.Data.GetFormats()[0]) as IWadObject != null) e.Effect = DragDropEffects.Copy; + else if (e.Data.GetData(e.Data.GetFormats()[0]) as IWadObject[] != null) + e.Effect = DragDropEffects.Copy; else if (e.Data.GetDataPresent(typeof(DarkFloatingToolboxContainer))) e.Effect = DragDropEffects.Move; else if (EditorActions.DragDropFileSupported(e, true)) diff --git a/TombEditor/Controls/Panel3D/MouseHandler/Panel3DMouseDown.cs b/TombEditor/Controls/Panel3D/MouseHandler/Panel3DMouseDown.cs index b27509e924..5713b53b90 100644 --- a/TombEditor/Controls/Panel3D/MouseHandler/Panel3DMouseDown.cs +++ b/TombEditor/Controls/Panel3D/MouseHandler/Panel3DMouseDown.cs @@ -1,10 +1,12 @@ using System.Drawing; +using System.Numerics; using System.Windows.Forms; using TombLib.Controls; using TombLib.Graphics; using TombLib.LevelData; using TombLib.Rendering; using TombLib; +using TombLib.Wad; using TombLib.LevelData.SectorEnums; using TombLib.LevelData.SectorEnums.Extensions; @@ -15,7 +17,8 @@ public partial class Panel3D private void OnMouseButtonDownLeft(Point location) { // Do picking on the scene - PickingResult newPicking = DoPicking(GetRay(location.X, location.Y), _editor.Configuration.Rendering3D_SelectObjectsInAnyRoom); + bool skipObjectPicking = _editor.Mode == EditorMode.ObjectPlacement && _editor.Tool.Tool != EditorToolType.Selection && !ModifierKeys.HasFlag(Keys.Alt); + var newPicking = DoPicking(GetRay(location.X, location.Y), _editor.Configuration.Rendering3D_SelectObjectsInAnyRoom, skipObjectPicking); if (newPicking is PickingResultSector) { @@ -55,7 +58,9 @@ private void OnMouseButtonDownLeft(Point location) VectorInt2 pos = newSectorPicking.Pos; // Handle face selection - if ((_editor.Tool.Tool == EditorToolType.Selection || _editor.Tool.Tool == EditorToolType.Group || _editor.Tool.Tool >= EditorToolType.Drag) && + if ((_editor.Tool.Tool == EditorToolType.Selection || _editor.Tool.Tool == EditorToolType.Group || + (_editor.Tool.Tool >= EditorToolType.Drag && _editor.Tool.Tool != EditorToolType.Eraser)) && + (_editor.Mode != EditorMode.ObjectPlacement || _editor.Tool.Tool == EditorToolType.Selection) && (ModifierKeys == Keys.None || ModifierKeys == Keys.Control)) { if (!_editor.SelectedSectors.Valid || !_editor.SelectedSectors.Area.Contains(pos)) @@ -151,6 +156,7 @@ private void OnMouseButtonDownLeft(Point location) case EditorMode.Lighting: case EditorMode.FaceEdit: + // Disable texturing in lighting mode, if option is set if (_editor.Mode == EditorMode.Lighting && !_editor.Configuration.Rendering3D_AllowTexturingInLightingMode) @@ -222,6 +228,11 @@ private void OnMouseButtonDownLeft(Point location) } break; + + case EditorMode.ObjectPlacement: + if (_editor.Tool.Tool != EditorToolType.Selection) + HandleObjectPlacementMouseDown(location); + break; } } else if (newPicking is PickingResultGizmo) @@ -248,10 +259,16 @@ private void OnMouseButtonDownLeft(Point location) if (ModifierKeys.HasFlag(Keys.Alt)) // Pick item or imported geo without selection { - if (obj is ItemInstance) - _editor.ChosenItem = ((ItemInstance)obj).ItemType; - else if (obj is ImportedGeometryInstance) - _editor.ChosenImportedGeometry = ((ImportedGeometryInstance)obj).Model; + if (obj is ItemInstance itemInstance) + { + var wadObj = itemInstance.ItemType.ToIWadObject(_editor.Level.Settings); + if (wadObj != null) + _editor.ChosenItems = new[] { wadObj }; + } + else if (obj is ImportedGeometryInstance geoInstance) + { + _editor.ChosenItems = new IWadObject[] { geoInstance.Model }; + } } else if (_editor.SelectedObject != obj) { diff --git a/TombEditor/Controls/Panel3D/MouseHandler/Panel3DMouseMove.cs b/TombEditor/Controls/Panel3D/MouseHandler/Panel3DMouseMove.cs index 091fa5cb82..e909913e42 100644 --- a/TombEditor/Controls/Panel3D/MouseHandler/Panel3DMouseMove.cs +++ b/TombEditor/Controls/Panel3D/MouseHandler/Panel3DMouseMove.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Drawing; -using System.Linq; using System.Numerics; using System.Windows.Forms; using TombLib; @@ -191,6 +190,9 @@ private bool OnMouseMovedLeft(Point location) } else { + if (HandleBrushMouseMove(location)) + return true; + var newSectorPicking = DoPicking(GetRay(location.X, location.Y)) as PickingResultSector; if (newSectorPicking != null) @@ -343,6 +345,8 @@ private bool OnMouseMovedLeft(Point location) private bool OnMouseMovedNone(Point location) { + HandleBrushMouseMove(location); + if (_editor.Tool.Tool != EditorToolType.GridPaint) return false; diff --git a/TombEditor/Controls/Panel3D/Panel3D.cs b/TombEditor/Controls/Panel3D/Panel3D.cs index 61eca6e9f3..667653af35 100644 --- a/TombEditor/Controls/Panel3D/Panel3D.cs +++ b/TombEditor/Controls/Panel3D/Panel3D.cs @@ -303,6 +303,9 @@ obj is Editor.ConfigurationChangedEvent || obj is SectorColoringManager.ChangeSectorColoringInfoEvent) _renderingCachedRooms.Clear(); + if (obj is Editor.ObjectBrushSettingsChangedEvent) + Invalidate(); + // Update drawing if (_editor.Mode != EditorMode.Map2D) if (obj is IEditorObjectChangedEvent || @@ -314,6 +317,7 @@ obj is Editor.SelectedSectorsChangedEvent || obj is Editor.HighlightedSectorChangedEvent || obj is Editor.SelectedRoomChangedEvent || obj is Editor.ModeChangedEvent || + obj is Editor.ToolChangedEvent || obj is Editor.LoadedWadsChangedEvent || obj is Editor.LoadedTexturesChangedEvent || obj is Editor.LoadedImportedGeometriesChangedEvent || @@ -396,7 +400,7 @@ protected override void OnKeyUp(KeyEventArgs e) protected override void OnMouseWheel(MouseEventArgs e) { base.OnMouseWheel(e); - OnMouseWheelScroll(e.Delta); + OnMouseWheelScroll(e.Delta, e.Location); } protected override void OnMouseDown(MouseEventArgs e) diff --git a/TombEditor/Controls/Panel3D/Panel3DDraw.cs b/TombEditor/Controls/Panel3D/Panel3DDraw.cs index a29d32b1d9..8e148d1701 100644 --- a/TombEditor/Controls/Panel3D/Panel3DDraw.cs +++ b/TombEditor/Controls/Panel3D/Panel3DDraw.cs @@ -8,6 +8,7 @@ using TombLib.Controls; using TombLib.Graphics; using TombLib.Graphics.Primitives; + using TombLib.LevelData; using TombLib.LevelData.SectorEnums; using TombLib.LevelData.SectorEnums.Extensions; @@ -1512,6 +1513,8 @@ private void DrawMoveables(List moveablesToDraw, List te var camPos = Camera.GetPosition(); var skinnedModelEffect = DeviceManager.DefaultDeviceManager.___LegacyEffects["Model"]; + ApplyBrushToModelEffect(skinnedModelEffect); + skinnedModelEffect.Parameters["AlphaTest"].SetValue(HideTransparentFaces); skinnedModelEffect.Parameters["ColoredVertices"].SetValue(_editor.Level.IsTombEngine); skinnedModelEffect.Parameters["Texture"].SetResource(_wadRenderer.Texture); @@ -1577,6 +1580,7 @@ private void DrawMoveables(List moveablesToDraw, List te } } + skinnedModelEffect.Parameters["WorldMatrix"].SetValue(instance.ObjectMatrix.ToSharpDX()); skin.RenderSkin(_legacyDevice, skinnedModelEffect, (instance.ObjectMatrix * _viewProjection).ToSharpDX(), model); } @@ -1622,6 +1626,7 @@ private void DrawMoveables(List moveablesToDraw, List te var world = model.AnimationTransforms[i] * instance.ObjectMatrix; skinnedModelEffect.Parameters["ModelViewProjection"].SetValue((world * _viewProjection).ToSharpDX()); + skinnedModelEffect.Parameters["WorldMatrix"].SetValue(world.ToSharpDX()); skinnedModelEffect.Techniques[0].Passes[0].Apply(); foreach (var submesh in mesh.Submeshes) @@ -1635,6 +1640,10 @@ private void DrawMoveables(List moveablesToDraw, List te } } } + + // Reset state. + ApplyBrushToModelEffect(skinnedModelEffect, true); + skinnedModelEffect.Techniques[0].Passes[0].Apply(); } private void DrawImportedGeometry(List importedGeometryToDraw, List textToDraw, bool disableSelection = false) @@ -1767,9 +1776,10 @@ private void DrawStatics(List staticsToDraw, List textToDr return; var staticMeshEffect = DeviceManager.DefaultDeviceManager.___LegacyEffects["Model"]; - var camPos = Camera.GetPosition(); + ApplyBrushToModelEffect(staticMeshEffect); + var groups = staticsToDraw.GroupBy(s => s.WadObjectId); foreach (var group in groups) { @@ -1818,6 +1828,7 @@ private void DrawStatics(List staticsToDraw, List textToDr } staticMeshEffect.Parameters["ModelViewProjection"].SetValue((instance.ObjectMatrix * _viewProjection).ToSharpDX()); + staticMeshEffect.Parameters["WorldMatrix"].SetValue(instance.ObjectMatrix.ToSharpDX()); staticMeshEffect.Parameters["AlphaTest"].SetValue(HideTransparentFaces); staticMeshEffect.Parameters["ColoredVertices"].SetValue(_editor.Level.IsTombEngine); staticMeshEffect.Parameters["TextureSampler"].SetResource(BilinearFilter ? _legacyDevice.SamplerStates.AnisotropicWrap : _legacyDevice.SamplerStates.PointWrap); @@ -1850,6 +1861,10 @@ private void DrawStatics(List staticsToDraw, List textToDr } } } + + // Reset state. + ApplyBrushToModelEffect(staticMeshEffect, true); + staticMeshEffect.Techniques[0].Passes[0].Apply(); } private void DrawScene() @@ -1894,15 +1909,28 @@ private void DrawScene() // New rendering setup _viewProjection = Camera.GetViewProjectionMatrix(ClientSize.Width, ClientSize.Height); + + // Determine brush overlay state. + var brushState = ComputeBrushOverlay(); + + // In ObjectPlacement (brush) mode, use only the brush-specific ShowTextures flag, + // the global white-lighting override is ignored so it doesn't bleed into brush mode. + bool brushHidesTextures = _editor.Mode == EditorMode.ObjectPlacement && !_editor.Configuration.ObjectBrush_ShowTextures; + bool whiteTextureOnly = _editor.Mode == EditorMode.ObjectPlacement ? brushHidesTextures : ShowLightingWhiteTextureOnly; + _renderingStateBuffer.Set(new RenderingState { ShowExtraBlendingModes = ShowExtraBlendingModes, - RoomGridForce = _editor.Mode == EditorMode.Geometry, - RoomDisableVertexColors = _editor.Mode == EditorMode.FaceEdit, + RoomGridForce = _editor.Mode == EditorMode.Geometry || brushHidesTextures, + RoomDisableVertexColors = _editor.Mode == EditorMode.FaceEdit || _editor.Mode == EditorMode.ObjectPlacement, RoomGridLineWidth = _editor.Configuration.Rendering3D_LineWidth, TransformMatrix = _viewProjection, - ShowLightingWhiteTextureOnly = ShowLightingWhiteTextureOnly, - LightMode = lightMode + ShowLightingWhiteTextureOnly = whiteTextureOnly, + LightMode = lightMode, + BrushShape = brushState.Shape, + BrushCenter = brushState.Center, + BrushColor = brushState.Color, + BrushRotation = brushState.Rotation }); var renderArgs = new RenderingDrawingRoom.RenderArgs diff --git a/TombEditor/Controls/Panel3D/Panel3DObjectBrush.cs b/TombEditor/Controls/Panel3D/Panel3DObjectBrush.cs new file mode 100644 index 0000000000..7670c6c528 --- /dev/null +++ b/TombEditor/Controls/Panel3D/Panel3DObjectBrush.cs @@ -0,0 +1,432 @@ +using SharpDX.Toolkit.Graphics; +using System; +using System.Drawing; +using System.Numerics; +using System.Windows.Forms; +using TombLib; +using TombLib.LevelData; + +namespace TombEditor.Controls.Panel3D +{ + public partial class Panel3D + { + // Brush stroke state. + private bool _brushEngaged; + private Vector3? _lastBrushWorldPosition; + private float? _lastBrushDirectionAngle; + + // Brush hover adjustment state. + private bool _brushParamEngaged = false; + private bool _brushParamDeadzoneExceeded = false; + private Vector3? _brushParamPinPoint; + private Vector2 _brushParamPinHoverPos; + + // Brush cursor state (replaces Editor cursor fields). + private Vector3? _brushCursorPosition; + private Room _brushCursorRoom; + + #region Ray and Picking Helpers + + // Compute world XZ position from a ray and picking distance. + private static Vector3 GetWorldPositionFromRay(Ray ray, float distance) + { + return new Vector3(ray.Position.X + ray.Direction.X * distance, 0, ray.Position.Z + ray.Direction.Z * distance); + } + + // Do floor-only picking and return world position and room, or null if no floor hit. + private (Vector3 WorldPos, Room Room)? PickBrushFloorPosition(Point location) + { + var ray = GetRay(location.X, location.Y); + + var picking = DoPicking(ray, skipObjects: true) as PickingResultSector; + if (picking == null || !picking.BelongsToFloor) + return null; + + return (GetWorldPositionFromRay(ray, picking.Distance), picking.Room); + } + + #endregion + + #region Brush Cursor State + + // Update brush cursor position for rendering overlay. + private void SetBrushCursor(Vector3 worldPos, Room room) + { + float localX = worldPos.X - room.WorldPos.X; + float localZ = worldPos.Z - room.WorldPos.Z; + float? floorH = ObjectBrush.Helper.GetFloorHeightAtPoint(room, localX, localZ); + float y = floorH.HasValue ? (floorH.Value + room.WorldPos.Y) : room.WorldPos.Y; + + _brushCursorPosition = new Vector3(worldPos.X, y, worldPos.Z); + _brushCursorRoom = room; + } + + #endregion + + #region Mouse Handlers + + private void HandleBrushMouseUp() + { + if (_brushEngaged) + ObjectBrush.Actions.EndBrushStroke(_editor); + + _brushEngaged = false; + _lastBrushWorldPosition = null; + _lastBrushDirectionAngle = null; + ObjectBrush.Actions.MouseDirectionAngle = null; + } + + // Returns true if the scroll event was consumed by the brush handler. + private bool HandleBrushWheelScroll(int delta, Point location) + { + if (_editor.Mode != EditorMode.ObjectPlacement) + return false; + + bool settingsChanged = false; + + // Alt + mousewheel adjusts brush rotation. + if (Control.ModifierKeys.HasFlag(Keys.Alt)) + { + float rotation = _editor.Configuration.ObjectBrush_Rotation + (delta > 0 ? ObjectBrush.Constants.AngleAdjustmentStep : -ObjectBrush.Constants.AngleAdjustmentStep); + rotation = ((rotation % 360.0f) + 360.0f) % 360.0f; + ObjectBrush.Actions.MouseDirectionAngle = _lastBrushDirectionAngle = _editor.Configuration.ObjectBrush_Rotation = + (float)(Math.Round(rotation / ObjectBrush.Constants.AngleAdjustmentStep) * ObjectBrush.Constants.AngleAdjustmentStep); + settingsChanged = true; + } + + if (Control.ModifierKeys.HasFlag(Keys.Shift)) + { + if (Control.ModifierKeys.HasFlag(Keys.Control)) + { + // Ctrl + shift + mousewheel adjusts brush density. + + float density = _editor.Configuration.ObjectBrush_Density + (delta > 0 ? 0.1f : -0.1f); + _editor.Configuration.ObjectBrush_Density = + Math.Min(ObjectBrush.Constants.MaxDensity, Math.Max(ObjectBrush.Constants.MinDensity, (float)Math.Round(density, 1))); + settingsChanged = true; + } + else + { + // Shift + mousewheel adjusts brush radius. + + float radius = _editor.Configuration.ObjectBrush_Radius + (delta > 0 ? ObjectBrush.Constants.RadiusAdjustmentStep : -ObjectBrush.Constants.RadiusAdjustmentStep); + _editor.Configuration.ObjectBrush_Radius = + Math.Min(ObjectBrush.Constants.MaxRadius * Level.SectorSizeUnit, Math.Max(ObjectBrush.Constants.MinRadius * Level.SectorSizeUnit, radius)); + settingsChanged = true; + } + } + + if (settingsChanged) + { + _brushParamPinHoverPos = new Vector2(location.X, location.Y); + _brushParamDeadzoneExceeded = false; + _editor.ObjectBrushSettingsChange(); + Invalidate(); + } + + return settingsChanged; + } + + private void HandleObjectPlacementMouseDown(Point location) + { + var floorHit = PickBrushFloorPosition(location); + if (!floorHit.HasValue) + return; + + if (_editor.Tool.Tool == EditorToolType.Fill) + { + // Fill executes immediately without brush engagement. + ObjectBrush.Actions.ExecuteFill(_editor, _editor.SelectedRoom); + } + else + { + _brushEngaged = true; + _lastBrushWorldPosition = ObjectBrush.Actions.BeginBrushStroke(_editor, _editor.SelectedRoom, floorHit.Value.WorldPos); + } + + Invalidate(); + } + + // Returns true if the brush consumed the mouse move event (redraw needed). + private bool HandleBrushMouseMove(Point location) + { + const float EraserQuantizationDistance = Level.SectorSizeUnit * 0.15f; + + if (_editor.Mode != EditorMode.ObjectPlacement || _editor.Tool.Tool == EditorToolType.Selection) + return false; + + // When brush is not engaged, just update the cursor position for visual display. + if (!_brushEngaged) + { + var hoverHit = PickBrushFloorPosition(location); + if (hoverHit.HasValue) + { + if (UpdateBrushParameters(location, hoverHit.Value.WorldPos)) + return true; + + SetBrushCursor(hoverHit.Value.WorldPos, hoverHit.Value.Room); + Invalidate(); + } + + return hoverHit.HasValue; + } + + var floorHit = PickBrushFloorPosition(location); + if (!floorHit.HasValue) + return true; + + // Update visible cursor. + SetBrushCursor(floorHit.Value.WorldPos, floorHit.Value.Room); + + // Eraser fires on fixed step; other tools quantize to avoid over-painting. + float quantizationDistance = _editor.Tool.Tool == EditorToolType.Eraser ? EraserQuantizationDistance : _editor.Configuration.ObjectBrush_Radius; + + // For Line tool, constrain movement to the rotation direction. + // Use bounding box extent along the rotation axis for seamless spacing. + if (_editor.Tool.Tool == EditorToolType.Line) + { + if (!_lastBrushWorldPosition.HasValue) + return true; + + float rotRad = _editor.Configuration.ObjectBrush_Rotation * (float)(Math.PI / 180.0); + var rotDir = new Vector3((float)Math.Sin(rotRad), 0, (float)Math.Cos(rotRad)); + + var delta = floorHit.Value.WorldPos - _lastBrushWorldPosition.Value; + delta.Y = 0; + + float proj = Vector3.Dot(delta, rotDir); + float spacing = ObjectBrush.Helper.ComputeLineSpacing(_editor); + + if (proj < spacing) + return true; + + // Snap to exact one-step advance from the last anchor for gapless tiling. + var snappedPos = _lastBrushWorldPosition.Value + rotDir * spacing; + bool painted = ObjectBrush.Actions.ContinueBrushStroke(_editor, _editor.SelectedRoom, snappedPos, null, spacing); + + if (painted) + { + _lastBrushWorldPosition = snappedPos; + Invalidate(); + } + } + else + { + // Track mouse movement direction for FollowMouseDirection mode. + UpdateMouseDirectionAngle(floorHit.Value.WorldPos); + + bool painted = ObjectBrush.Actions.ContinueBrushStroke(_editor, _editor.SelectedRoom, + floorHit.Value.WorldPos, _lastBrushWorldPosition, quantizationDistance); + + if (painted) + { + _lastBrushWorldPosition = floorHit.Value.WorldPos; + Invalidate(); + } + } + + return true; + } + + #endregion + + #region Mouse Direction Tracking + + private void UpdateMouseDirectionAngle(Vector3 currentPos) + { + if (!_lastBrushWorldPosition.HasValue) + return; + + float dx = currentPos.X - _lastBrushWorldPosition.Value.X; + float dz = currentPos.Z - _lastBrushWorldPosition.Value.Z; + + if (dx * dx + dz * dz <= 0.01f) + return; + + float angle = (float)(Math.Atan2(dx, dz) * (180.0f / Math.PI)); + angle = ((angle % 360.0f) + 360.0f) % 360.0f; + + if (_lastBrushDirectionAngle.HasValue) + { + float diff = ((angle - _lastBrushDirectionAngle.Value + 540.0f) % 360.0f) - 180.0f; + _lastBrushDirectionAngle = ((_lastBrushDirectionAngle.Value + diff * 0.35f) % 360.0f + 360.0f) % 360.0f; + } + else + _lastBrushDirectionAngle = angle; + + ObjectBrush.Actions.MouseDirectionAngle = _lastBrushDirectionAngle; + } + + #endregion + + #region Parameter Handling + + // Adjusts ObjectBrush parameters based on modifier keys held during hover or brush stroke. + // Alt: rotation tracks mouse movement direction. + // Shift: radius = distance from pinned center to current cursor position. + // Ctrl+Shift: density scales with distance from pin point. + // Returns true if any parameter was modified. + + internal bool UpdateBrushParameters(Point location, Vector3 cursorWorldPos) + { + if (Control.ModifierKeys.HasFlag(Keys.Shift) || + Control.ModifierKeys.HasFlag(Keys.Alt)) + { + if (!_brushParamEngaged) + { + _brushParamPinPoint = cursorWorldPos; + _brushParamEngaged = true; + _brushParamPinHoverPos = new Vector2(location.X, location.Y); + } + } + else + { + _brushParamEngaged = false; + _brushParamDeadzoneExceeded = false; + return false; + } + + float screenDistance = Vector2.Distance(new Vector2(location.X, location.Y), _brushParamPinHoverPos); + + // Skip parameter update until the cursor has moved past the deadzone from the pin point. + // Once exceeded, keep updating even if cursor moves back inside the deadzone. + if (!_brushParamDeadzoneExceeded) + { + if (screenDistance < ObjectBrush.Constants.ParamDeadzone) + return true; + + _brushParamDeadzoneExceeded = true; + } + + bool settingsChanged = false; + + if (Control.ModifierKeys.HasFlag(Keys.Shift)) + { + if (Control.ModifierKeys.HasFlag(Keys.Control)) + { + // Ctrl+Shift: density scales with distance from pin point. + var density = (screenDistance / this.Size.Height) * ObjectBrush.Constants.MaxDensity; + + _editor.Configuration.ObjectBrush_Density = Math.Min(ObjectBrush.Constants.MaxDensity, + Math.Max(ObjectBrush.Constants.MinDensity, density)); + + settingsChanged = true; + } + else + { + // Shift alone: radius = distance from pinned center to current cursor position. + float distance = Vector2.Distance(new Vector2(cursorWorldPos.X, cursorWorldPos.Z), + new Vector2(_brushParamPinPoint.Value.X, _brushParamPinPoint.Value.Z)); + + _editor.Configuration.ObjectBrush_Radius = Math.Min(ObjectBrush.Constants.MaxRadius * Level.SectorSizeUnit, + Math.Max(ObjectBrush.Constants.MinRadius * Level.SectorSizeUnit, distance)); + + settingsChanged = true; + } + } + + // Alt: rotation = smoothed angle from pin point to current cursor position. + if (Control.ModifierKeys.HasFlag(Keys.Alt)) + { + _lastBrushWorldPosition = _brushParamPinPoint; + UpdateMouseDirectionAngle(cursorWorldPos); + + if (_lastBrushDirectionAngle.HasValue) + { + _editor.Configuration.ObjectBrush_Rotation = _lastBrushDirectionAngle.Value; + settingsChanged = true; + } + } + + if (settingsChanged) + _editor.ObjectBrushSettingsChange(); + + return settingsChanged; + } + + #endregion + + #region Rendering Helpers + + private struct BrushOverlayState + { + public int Shape; + public Vector4 Center; + public Vector4 Color; + public float Rotation; + } + + // Compute brush overlay parameters. Returns null if brush is inactive. + private BrushOverlayState ComputeBrushOverlay(bool reset = false) + { + const float MinBrushTransparency = 0.1f; + const float MaxBrushTransparency = 0.4f; + + if (_editor.Mode != EditorMode.ObjectPlacement || _editor.Tool.Tool == EditorToolType.Selection || !_brushCursorPosition.HasValue || _brushCursorRoom == null) + reset = true; + + int shape = 0; + var center = Vector4.Zero; + var rot = -1.0f; + var density = 0.25f; + var color = Vector4.Zero; + + if (!reset) + { + color = Vector4.One; + + if (_editor.Tool.Tool != EditorToolType.ObjectSelection && + _editor.Tool.Tool != EditorToolType.ObjectDeselection && + _editor.Tool.Tool != EditorToolType.Line && + _editor.Tool.Tool != EditorToolType.Pencil) + { + density = Math.Min(MaxBrushTransparency, Math.Max(MinBrushTransparency, _editor.Configuration.ObjectBrush_Density / ObjectBrush.Constants.MaxDensity)); + } + + var cursorPos = _brushCursorPosition.HasValue ? _brushCursorPosition.Value : Vector3.Zero; + + if (_editor.Tool.Tool == EditorToolType.Fill) + { + const float FillBrushSize = 0.2f; + + shape = (int)ObjectBrushShape.Circle + 1; + center = new Vector4(cursorPos.X, cursorPos.Y, cursorPos.Z, Level.SectorSizeUnit * FillBrushSize); + + if (!_editor.Configuration.ObjectBrush_RandomizeRotation) + rot = _editor.Configuration.ObjectBrush_Rotation; + } + else + { + shape = (int)_editor.Configuration.ObjectBrush_Shape + 1; + center = new Vector4(cursorPos.X, cursorPos.Y, cursorPos.Z, _editor.Configuration.ObjectBrush_Radius); + + if (_editor.Tool.Tool != EditorToolType.ObjectSelection && _editor.Tool.Tool != EditorToolType.ObjectDeselection && _editor.Tool.Tool != EditorToolType.Eraser) + { + if (_editor.Tool.Tool != EditorToolType.Line && _editor.Configuration.ObjectBrush_FollowMouseDirection && _lastBrushDirectionAngle.HasValue) + rot = _lastBrushDirectionAngle.Value; + else if (_editor.Tool.Tool == EditorToolType.Line || !_editor.Configuration.ObjectBrush_RandomizeRotation || _editor.Configuration.ObjectBrush_FollowMouseDirection) + rot = _editor.Configuration.ObjectBrush_Rotation; + } + } + + color.W = density; + } + + return new BrushOverlayState { Shape = shape, Center = center, Color = color, Rotation = rot }; + } + + // Apply brush overlay parameters to a model effect shader. + internal void ApplyBrushToModelEffect(Effect effect, bool reset = false) + { + var overlay = ComputeBrushOverlay(reset); + + effect.Parameters["BrushShape"].SetValue(overlay.Shape); + effect.Parameters["BrushCenter"].SetValue(overlay.Center); + effect.Parameters["BrushColor"].SetValue(overlay.Color); + effect.Parameters["BrushRotation"].SetValue(overlay.Rotation); + effect.Parameters["BrushLineWidth"].SetValue(_editor.Configuration.Rendering3D_LineWidth); + } + + #endregion + } +} diff --git a/TombEditor/Controls/Panel3D/Panel3DPicking.cs b/TombEditor/Controls/Panel3D/Panel3DPicking.cs index 789ccaf490..d73575f41d 100644 --- a/TombEditor/Controls/Panel3D/Panel3DPicking.cs +++ b/TombEditor/Controls/Panel3D/Panel3DPicking.cs @@ -109,7 +109,7 @@ private void DoMeshPicking(ref PickingResult result, Ray ray, ObjectInstance result = new PickingResultObject(TransformRayDistance(ref transformedRay, ref objectMatrix, ref ray, minDistance), objectPtr); } - private PickingResult DoPicking(Ray ray, bool pickAnyRoom = false) + private PickingResult DoPicking(Ray ray, bool pickAnyRoom = false, bool skipObjects = false) { // The gizmo has the priority because it always drawn on top PickingResult result = _gizmo.DoPicking(ray); @@ -123,6 +123,7 @@ private PickingResult DoPicking(Ray ray, bool pickAnyRoom = false) float distance; // First check for all objects in the room + if (!skipObjects) foreach (var instance in room.Objects) if (instance is MoveableInstance) { @@ -190,7 +191,7 @@ private PickingResult DoPicking(Ray ray, bool pickAnyRoom = false) else if (ShowOtherObjects) result = TryPickServiceObject(instance, ray, result, out distance); - if (ShowGhostBlocks) + if (!skipObjects && ShowGhostBlocks) foreach (var ghost in room.GhostBlocks) { if (_editor.SelectedObject == ghost) diff --git a/TombEditor/Controls/PanelRenderingImportedGeometry.cs b/TombEditor/Controls/PanelRenderingImportedGeometry.cs index f9dbfb9c7a..a25037c1e3 100644 --- a/TombEditor/Controls/PanelRenderingImportedGeometry.cs +++ b/TombEditor/Controls/PanelRenderingImportedGeometry.cs @@ -40,14 +40,15 @@ private void EditorEventRaised(IEditorEvent obj) Invalidate(); } - // Update currently viewed item - if (obj is Editor.ChosenImportedGeometryChangedEvent) + // Update currently viewed item. + if (obj is Editor.ChosenItemsChangedEvent itemsChanged) { - Editor.ChosenImportedGeometryChangedEvent e = (Editor.ChosenImportedGeometryChangedEvent)obj; - if (e.Current != null) + if (itemsChanged.Current?.Any(o => o is ImportedGeometry) == true) + { ResetCamera(); - Invalidate(); - Update(); // Magic fix for room view leaking into item view + Invalidate(); + Update(); // Magic fix for room view leaking into item view + } } if (obj is Editor.LoadedImportedGeometriesChangedEvent || diff --git a/TombEditor/Controls/PanelRenderingItem.cs b/TombEditor/Controls/PanelRenderingItem.cs index b680bcd06f..791d4d17db 100644 --- a/TombEditor/Controls/PanelRenderingItem.cs +++ b/TombEditor/Controls/PanelRenderingItem.cs @@ -8,6 +8,7 @@ using TombLib.Controls; using TombLib.LevelData; using TombLib.Utils; +using TombLib.Wad; namespace TombEditor.Controls { @@ -40,14 +41,15 @@ private void EditorEventRaised(IEditorEvent obj) Invalidate(); } - // Update currently viewed item - if (obj is Editor.ChosenItemChangedEvent) + // Update currently viewed item. + if (obj is Editor.ChosenItemsChangedEvent itemsChanged) { - Editor.ChosenItemChangedEvent e = (Editor.ChosenItemChangedEvent)obj; - if (e.Current != null) + if (itemsChanged.Current?.Any(o => o is WadMoveable or WadStatic) == true) + { ResetCamera(); - Invalidate(); - Update(); // Magic fix for room view leaking into item view + Invalidate(); + Update(); // Magic fix for room view leaking into item view + } } if (obj is Editor.LoadedWadsChangedEvent || @@ -109,18 +111,10 @@ protected override void OnMouseDown(MouseEventArgs e) else EditorActions.AddWad(Parent); } - else if (_editor.ChosenItem != null) + else { - if (_editor.ChosenItem.Value.IsStatic) - { - var stat = _editor.Level.Settings.WadTryGetStatic(_editor.ChosenItem.Value.StaticId); - if (stat != null) DoDragDrop(stat, DragDropEffects.Copy); - } - else - { - var mov = _editor.Level.Settings.WadTryGetMoveable(_editor.ChosenItem.Value.MoveableId); - if (mov != null) DoDragDrop(mov, DragDropEffects.Copy); - } + if (CurrentObject != null) + DoDragDrop(CurrentObject, DragDropEffects.Copy); } break; } diff --git a/TombEditor/Controls/ToolBox.Designer.cs b/TombEditor/Controls/ToolBox.Designer.cs index 96fb1e5e46..8d18bacb68 100644 --- a/TombEditor/Controls/ToolBox.Designer.cs +++ b/TombEditor/Controls/ToolBox.Designer.cs @@ -2,414 +2,49 @@ { partial class ToolBox { - /// - /// Required designer variable. - /// private System.ComponentModel.IContainer components = null; - /// - /// Clean up any resources being used. - /// - /// true if managed resources should be disposed; otherwise, false. protected override void Dispose(bool disposing) { - if (disposing && (components != null)) + if (disposing) { - components.Dispose(); + _toolBoxView?.Cleanup(); + components?.Dispose(); } + base.Dispose(disposing); } #region Component Designer generated code - /// - /// Required method for Designer support - do not modify - /// the contents of this method with the code editor. - /// private void InitializeComponent() { - this.toolStrip = new DarkUI.Controls.DarkToolStrip(); - this.toolSelection = new System.Windows.Forms.ToolStripButton(); - this.toolBrush = new System.Windows.Forms.ToolStripButton(); - this.toolShovel = new System.Windows.Forms.ToolStripButton(); - this.toolPencil = new System.Windows.Forms.ToolStripButton(); - this.toolFlatten = new System.Windows.Forms.ToolStripButton(); - this.toolSmooth = new System.Windows.Forms.ToolStripButton(); - this.toolFill = new System.Windows.Forms.ToolStripButton(); - this.toolGridPaint = new System.Windows.Forms.ToolStripButton(); - this.toolGroup = new System.Windows.Forms.ToolStripButton(); - this.toolSeparator1 = new System.Windows.Forms.ToolStripSeparator(); - this.toolDrag = new System.Windows.Forms.ToolStripButton(); - this.toolRamp = new System.Windows.Forms.ToolStripButton(); - this.toolQuarterPipe = new System.Windows.Forms.ToolStripButton(); - this.toolHalfPipe = new System.Windows.Forms.ToolStripButton(); - this.toolBowl = new System.Windows.Forms.ToolStripButton(); - this.toolPyramid = new System.Windows.Forms.ToolStripButton(); - this.toolTerrain = new System.Windows.Forms.ToolStripButton(); - this.toolEraser = new System.Windows.Forms.ToolStripButton(); - this.toolInvisibility = new System.Windows.Forms.ToolStripButton(); - this.toolSeparator2 = new System.Windows.Forms.ToolStripSeparator(); - this.toolPortalDigger = new System.Windows.Forms.ToolStripButton(); - this.toolUVFixer = new System.Windows.Forms.ToolStripButton(); - this.toolStrip.SuspendLayout(); - this.SuspendLayout(); - // - // toolStrip - // - this.toolStrip.AutoSize = false; - this.toolStrip.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(60)))), ((int)(((byte)(63)))), ((int)(((byte)(65))))); - this.toolStrip.CanOverflow = false; - this.toolStrip.Dock = System.Windows.Forms.DockStyle.Fill; - this.toolStrip.ForeColor = System.Drawing.Color.FromArgb(((int)(((byte)(220)))), ((int)(((byte)(220)))), ((int)(((byte)(220))))); - this.toolStrip.GripStyle = System.Windows.Forms.ToolStripGripStyle.Hidden; - this.toolStrip.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { - this.toolSelection, - this.toolBrush, - this.toolShovel, - this.toolPencil, - this.toolFlatten, - this.toolSmooth, - this.toolFill, - this.toolGridPaint, - this.toolGroup, - this.toolSeparator1, - this.toolDrag, - this.toolRamp, - this.toolQuarterPipe, - this.toolHalfPipe, - this.toolBowl, - this.toolPyramid, - this.toolTerrain, - this.toolEraser, - this.toolInvisibility, - this.toolSeparator2, - this.toolPortalDigger, - this.toolUVFixer}); - this.toolStrip.LayoutStyle = System.Windows.Forms.ToolStripLayoutStyle.VerticalStackWithOverflow; - this.toolStrip.Location = new System.Drawing.Point(0, 0); - this.toolStrip.Name = "toolStrip"; - this.toolStrip.Padding = new System.Windows.Forms.Padding(1, 0, 1, 0); - this.toolStrip.Size = new System.Drawing.Size(28, 453); - this.toolStrip.TabIndex = 3; - // - // toolSelection - // - this.toolSelection.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(60)))), ((int)(((byte)(63)))), ((int)(((byte)(65))))); - this.toolSelection.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Image; - this.toolSelection.ForeColor = System.Drawing.Color.FromArgb(((int)(((byte)(220)))), ((int)(((byte)(220)))), ((int)(((byte)(220))))); - this.toolSelection.Image = global::TombEditor.Properties.Resources.toolbox_Selection_16; - this.toolSelection.ImageTransparentColor = System.Drawing.Color.Magenta; - this.toolSelection.Margin = new System.Windows.Forms.Padding(1); - this.toolSelection.Name = "toolSelection"; - this.toolSelection.Size = new System.Drawing.Size(23, 20); - this.toolSelection.ToolTipText = "Selection"; - this.toolSelection.Click += new System.EventHandler(this.toolSelection_Click); - // - // toolBrush - // - this.toolBrush.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(60)))), ((int)(((byte)(63)))), ((int)(((byte)(65))))); - this.toolBrush.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Image; - this.toolBrush.ForeColor = System.Drawing.Color.FromArgb(((int)(((byte)(220)))), ((int)(((byte)(220)))), ((int)(((byte)(220))))); - this.toolBrush.Image = global::TombEditor.Properties.Resources.toolbox_Paint_16; - this.toolBrush.ImageTransparentColor = System.Drawing.Color.Magenta; - this.toolBrush.Margin = new System.Windows.Forms.Padding(1); - this.toolBrush.Name = "toolBrush"; - this.toolBrush.Size = new System.Drawing.Size(23, 20); - this.toolBrush.ToolTipText = "Brush"; - this.toolBrush.Click += new System.EventHandler(this.toolBrush_Click); - // - // toolShovel - // - this.toolShovel.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(60)))), ((int)(((byte)(63)))), ((int)(((byte)(65))))); - this.toolShovel.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Image; - this.toolShovel.ForeColor = System.Drawing.Color.FromArgb(((int)(((byte)(220)))), ((int)(((byte)(220)))), ((int)(((byte)(220))))); - this.toolShovel.Image = global::TombEditor.Properties.Resources.toolbox_Shovel_16; - this.toolShovel.ImageTransparentColor = System.Drawing.Color.Magenta; - this.toolShovel.Margin = new System.Windows.Forms.Padding(1); - this.toolShovel.Name = "toolShovel"; - this.toolShovel.Size = new System.Drawing.Size(23, 20); - this.toolShovel.ToolTipText = "Shovel"; - this.toolShovel.Click += new System.EventHandler(this.toolShovel_Click); - // - // toolPencil - // - this.toolPencil.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(60)))), ((int)(((byte)(63)))), ((int)(((byte)(65))))); - this.toolPencil.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Image; - this.toolPencil.ForeColor = System.Drawing.Color.FromArgb(((int)(((byte)(220)))), ((int)(((byte)(220)))), ((int)(((byte)(220))))); - this.toolPencil.Image = global::TombEditor.Properties.Resources.toolbox_Pencil_16; - this.toolPencil.ImageTransparentColor = System.Drawing.Color.Magenta; - this.toolPencil.Margin = new System.Windows.Forms.Padding(1); - this.toolPencil.Name = "toolPencil"; - this.toolPencil.Size = new System.Drawing.Size(23, 20); - this.toolPencil.ToolTipText = "Pencil"; - this.toolPencil.Click += new System.EventHandler(this.toolPencil_Click); - // - // toolFlatten - // - this.toolFlatten.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(60)))), ((int)(((byte)(63)))), ((int)(((byte)(65))))); - this.toolFlatten.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Image; - this.toolFlatten.ForeColor = System.Drawing.Color.FromArgb(((int)(((byte)(220)))), ((int)(((byte)(220)))), ((int)(((byte)(220))))); - this.toolFlatten.Image = global::TombEditor.Properties.Resources.toolbox_Bulldozer_1_16; - this.toolFlatten.ImageTransparentColor = System.Drawing.Color.Magenta; - this.toolFlatten.Margin = new System.Windows.Forms.Padding(1); - this.toolFlatten.Name = "toolFlatten"; - this.toolFlatten.Size = new System.Drawing.Size(23, 20); - this.toolFlatten.ToolTipText = "Bulldozer"; - this.toolFlatten.Click += new System.EventHandler(this.toolFlatten_Click); - // - // toolSmooth - // - this.toolSmooth.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(60)))), ((int)(((byte)(63)))), ((int)(((byte)(65))))); - this.toolSmooth.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Image; - this.toolSmooth.ForeColor = System.Drawing.Color.FromArgb(((int)(((byte)(220)))), ((int)(((byte)(220)))), ((int)(((byte)(220))))); - this.toolSmooth.Image = global::TombEditor.Properties.Resources.toolbox_Smooth_16; - this.toolSmooth.ImageTransparentColor = System.Drawing.Color.Magenta; - this.toolSmooth.Margin = new System.Windows.Forms.Padding(1); - this.toolSmooth.Name = "toolSmooth"; - this.toolSmooth.Size = new System.Drawing.Size(23, 20); - this.toolSmooth.ToolTipText = "Smooth"; - this.toolSmooth.Click += new System.EventHandler(this.toolSmooth_Click); - // - // toolFill - // - this.toolFill.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(60)))), ((int)(((byte)(63)))), ((int)(((byte)(65))))); - this.toolFill.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Image; - this.toolFill.ForeColor = System.Drawing.Color.FromArgb(((int)(((byte)(220)))), ((int)(((byte)(220)))), ((int)(((byte)(220))))); - this.toolFill.Image = global::TombEditor.Properties.Resources.toolbox_Fill_16; - this.toolFill.ImageTransparentColor = System.Drawing.Color.Magenta; - this.toolFill.Margin = new System.Windows.Forms.Padding(1); - this.toolFill.Name = "toolFill"; - this.toolFill.Size = new System.Drawing.Size(23, 20); - this.toolFill.ToolTipText = "Fill"; - this.toolFill.Click += new System.EventHandler(this.toolFill_Click); - // - // toolGridPaint - // - this.toolGridPaint.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(60)))), ((int)(((byte)(63)))), ((int)(((byte)(65))))); - this.toolGridPaint.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Image; - this.toolGridPaint.ForeColor = System.Drawing.Color.FromArgb(((int)(((byte)(220)))), ((int)(((byte)(220)))), ((int)(((byte)(220))))); - this.toolGridPaint.Image = global::TombEditor.Properties.Resources.toolbox_Paint2x2_16; - this.toolGridPaint.ImageTransparentColor = System.Drawing.Color.Magenta; - this.toolGridPaint.Margin = new System.Windows.Forms.Padding(1); - this.toolGridPaint.Name = "toolGridPaint"; - this.toolGridPaint.Size = new System.Drawing.Size(23, 20); - this.toolGridPaint.ToolTipText = "Grid Paint (2x2)"; - this.toolGridPaint.Click += new System.EventHandler(this.tooPaint2x2_Click); - this.toolGridPaint.MouseDown += new System.Windows.Forms.MouseEventHandler(this.toolGridPaint_MouseDown); - this.toolGridPaint.MouseUp += new System.Windows.Forms.MouseEventHandler(this.toolGridPaint_MouseUp); - // - // toolGroup - // - this.toolGroup.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(60)))), ((int)(((byte)(63)))), ((int)(((byte)(65))))); - this.toolGroup.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Image; - this.toolGroup.ForeColor = System.Drawing.Color.FromArgb(((int)(((byte)(220)))), ((int)(((byte)(220)))), ((int)(((byte)(220))))); - this.toolGroup.Image = global::TombEditor.Properties.Resources.toolbox_GroupTexture_16; - this.toolGroup.ImageTransparentColor = System.Drawing.Color.Magenta; - this.toolGroup.Margin = new System.Windows.Forms.Padding(1); - this.toolGroup.Name = "toolGroup"; - this.toolGroup.Size = new System.Drawing.Size(23, 20); - this.toolGroup.ToolTipText = "Group Texturing"; - this.toolGroup.Click += new System.EventHandler(this.toolGroup_Click); - // - // toolSeparator1 - // - this.toolSeparator1.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(60)))), ((int)(((byte)(63)))), ((int)(((byte)(65))))); - this.toolSeparator1.ForeColor = System.Drawing.Color.FromArgb(((int)(((byte)(220)))), ((int)(((byte)(220)))), ((int)(((byte)(220))))); - this.toolSeparator1.Margin = new System.Windows.Forms.Padding(0, 0, 2, 0); - this.toolSeparator1.Name = "toolSeparator1"; - this.toolSeparator1.Size = new System.Drawing.Size(23, 6); + _elementHost = new System.Windows.Forms.Integration.ElementHost(); + _toolBoxView = new Views.ToolBoxView(); + SuspendLayout(); // - // toolDrag + // _elementHost // - this.toolDrag.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(60)))), ((int)(((byte)(63)))), ((int)(((byte)(65))))); - this.toolDrag.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Image; - this.toolDrag.ForeColor = System.Drawing.Color.FromArgb(((int)(((byte)(220)))), ((int)(((byte)(220)))), ((int)(((byte)(220))))); - this.toolDrag.Image = global::TombEditor.Properties.Resources.toolbox_Drag_16; - this.toolDrag.ImageTransparentColor = System.Drawing.Color.Magenta; - this.toolDrag.Margin = new System.Windows.Forms.Padding(1); - this.toolDrag.Name = "toolDrag"; - this.toolDrag.Size = new System.Drawing.Size(23, 20); - this.toolDrag.ToolTipText = "Drag"; - this.toolDrag.Click += new System.EventHandler(this.toolDrag_Click); - // - // toolRamp - // - this.toolRamp.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(60)))), ((int)(((byte)(63)))), ((int)(((byte)(65))))); - this.toolRamp.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Image; - this.toolRamp.ForeColor = System.Drawing.Color.FromArgb(((int)(((byte)(220)))), ((int)(((byte)(220)))), ((int)(((byte)(220))))); - this.toolRamp.Image = global::TombEditor.Properties.Resources.toolbox_GroupRamp_16; - this.toolRamp.ImageTransparentColor = System.Drawing.Color.Magenta; - this.toolRamp.Margin = new System.Windows.Forms.Padding(1); - this.toolRamp.Name = "toolRamp"; - this.toolRamp.Size = new System.Drawing.Size(23, 20); - this.toolRamp.ToolTipText = "Ramp"; - this.toolRamp.Click += new System.EventHandler(this.toolRamp_Click); - // - // toolQuarterPipe - // - this.toolQuarterPipe.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(60)))), ((int)(((byte)(63)))), ((int)(((byte)(65))))); - this.toolQuarterPipe.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Image; - this.toolQuarterPipe.ForeColor = System.Drawing.Color.FromArgb(((int)(((byte)(220)))), ((int)(((byte)(220)))), ((int)(((byte)(220))))); - this.toolQuarterPipe.Image = global::TombEditor.Properties.Resources.toolbox_GroupQuaterPipe_16; - this.toolQuarterPipe.ImageTransparentColor = System.Drawing.Color.Magenta; - this.toolQuarterPipe.Margin = new System.Windows.Forms.Padding(1); - this.toolQuarterPipe.Name = "toolQuarterPipe"; - this.toolQuarterPipe.Size = new System.Drawing.Size(23, 20); - this.toolQuarterPipe.ToolTipText = "Quarter Pipe"; - this.toolQuarterPipe.Click += new System.EventHandler(this.toolQuarterPipe_Click); - // - // toolHalfPipe - // - this.toolHalfPipe.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(60)))), ((int)(((byte)(63)))), ((int)(((byte)(65))))); - this.toolHalfPipe.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Image; - this.toolHalfPipe.ForeColor = System.Drawing.Color.FromArgb(((int)(((byte)(220)))), ((int)(((byte)(220)))), ((int)(((byte)(220))))); - this.toolHalfPipe.Image = global::TombEditor.Properties.Resources.toolbox_GroupHalfPipe_16; - this.toolHalfPipe.ImageTransparentColor = System.Drawing.Color.Magenta; - this.toolHalfPipe.Margin = new System.Windows.Forms.Padding(1); - this.toolHalfPipe.Name = "toolHalfPipe"; - this.toolHalfPipe.Size = new System.Drawing.Size(23, 20); - this.toolHalfPipe.ToolTipText = "Half Pipe"; - this.toolHalfPipe.Click += new System.EventHandler(this.toolHalfPipe_Click); - // - // toolBowl - // - this.toolBowl.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(60)))), ((int)(((byte)(63)))), ((int)(((byte)(65))))); - this.toolBowl.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Image; - this.toolBowl.ForeColor = System.Drawing.Color.FromArgb(((int)(((byte)(220)))), ((int)(((byte)(220)))), ((int)(((byte)(220))))); - this.toolBowl.Image = global::TombEditor.Properties.Resources.toolbox_GroupBowl_16; - this.toolBowl.ImageTransparentColor = System.Drawing.Color.Magenta; - this.toolBowl.Margin = new System.Windows.Forms.Padding(1); - this.toolBowl.Name = "toolBowl"; - this.toolBowl.Size = new System.Drawing.Size(23, 20); - this.toolBowl.ToolTipText = "Bowl"; - this.toolBowl.Click += new System.EventHandler(this.toolBowl_Click); - // - // toolPyramid - // - this.toolPyramid.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(60)))), ((int)(((byte)(63)))), ((int)(((byte)(65))))); - this.toolPyramid.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Image; - this.toolPyramid.ForeColor = System.Drawing.Color.FromArgb(((int)(((byte)(220)))), ((int)(((byte)(220)))), ((int)(((byte)(220))))); - this.toolPyramid.Image = global::TombEditor.Properties.Resources.toolbox_GroupPyramid_16; - this.toolPyramid.ImageTransparentColor = System.Drawing.Color.Magenta; - this.toolPyramid.Margin = new System.Windows.Forms.Padding(1); - this.toolPyramid.Name = "toolPyramid"; - this.toolPyramid.Size = new System.Drawing.Size(23, 20); - this.toolPyramid.ToolTipText = "Pyramid"; - this.toolPyramid.Click += new System.EventHandler(this.toolPyramid_Click); - // - // toolTerrain - // - this.toolTerrain.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(60)))), ((int)(((byte)(63)))), ((int)(((byte)(65))))); - this.toolTerrain.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Image; - this.toolTerrain.ForeColor = System.Drawing.Color.FromArgb(((int)(((byte)(220)))), ((int)(((byte)(220)))), ((int)(((byte)(220))))); - this.toolTerrain.Image = global::TombEditor.Properties.Resources.toolbox_GroupTerrain_16; - this.toolTerrain.ImageTransparentColor = System.Drawing.Color.Magenta; - this.toolTerrain.Margin = new System.Windows.Forms.Padding(1); - this.toolTerrain.Name = "toolTerrain"; - this.toolTerrain.Size = new System.Drawing.Size(23, 20); - this.toolTerrain.ToolTipText = "Terrain"; - this.toolTerrain.Click += new System.EventHandler(this.toolTerrain_Click); - // - // toolEraser - // - this.toolEraser.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(60)))), ((int)(((byte)(63)))), ((int)(((byte)(65))))); - this.toolEraser.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Image; - this.toolEraser.ForeColor = System.Drawing.Color.FromArgb(((int)(((byte)(220)))), ((int)(((byte)(220)))), ((int)(((byte)(220))))); - this.toolEraser.Image = global::TombEditor.Properties.Resources.toolbox_Eraser_16; - this.toolEraser.ImageTransparentColor = System.Drawing.Color.Magenta; - this.toolEraser.Margin = new System.Windows.Forms.Padding(1); - this.toolEraser.Name = "toolEraser"; - this.toolEraser.Size = new System.Drawing.Size(23, 20); - this.toolEraser.ToolTipText = "Eraser"; - this.toolEraser.Click += new System.EventHandler(this.toolEraser_Click); - // - // toolInvisibility - // - this.toolInvisibility.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(60)))), ((int)(((byte)(63)))), ((int)(((byte)(65))))); - this.toolInvisibility.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Image; - this.toolInvisibility.ForeColor = System.Drawing.Color.FromArgb(((int)(((byte)(220)))), ((int)(((byte)(220)))), ((int)(((byte)(220))))); - this.toolInvisibility.Image = global::TombEditor.Properties.Resources.toolbox_Invisible_16; - this.toolInvisibility.ImageTransparentColor = System.Drawing.Color.Magenta; - this.toolInvisibility.Margin = new System.Windows.Forms.Padding(1); - this.toolInvisibility.Name = "toolInvisibility"; - this.toolInvisibility.Size = new System.Drawing.Size(23, 20); - this.toolInvisibility.ToolTipText = "Invisibility"; - this.toolInvisibility.Click += new System.EventHandler(this.toolInvisibility_Click); - // - // toolSeparator2 - // - this.toolSeparator2.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(60)))), ((int)(((byte)(63)))), ((int)(((byte)(65))))); - this.toolSeparator2.ForeColor = System.Drawing.Color.FromArgb(((int)(((byte)(220)))), ((int)(((byte)(220)))), ((int)(((byte)(220))))); - this.toolSeparator2.Margin = new System.Windows.Forms.Padding(0, 0, 2, 0); - this.toolSeparator2.Name = "toolSeparator2"; - this.toolSeparator2.Size = new System.Drawing.Size(23, 6); - // - // toolPortalDigger - // - this.toolPortalDigger.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(60)))), ((int)(((byte)(63)))), ((int)(((byte)(65))))); - this.toolPortalDigger.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Image; - this.toolPortalDigger.ForeColor = System.Drawing.Color.FromArgb(((int)(((byte)(220)))), ((int)(((byte)(220)))), ((int)(((byte)(220))))); - this.toolPortalDigger.Image = global::TombEditor.Properties.Resources.toolbox_PortalDigger_16; - this.toolPortalDigger.ImageTransparentColor = System.Drawing.Color.Magenta; - this.toolPortalDigger.Margin = new System.Windows.Forms.Padding(1); - this.toolPortalDigger.Name = "toolPortalDigger"; - this.toolPortalDigger.Size = new System.Drawing.Size(23, 20); - this.toolPortalDigger.ToolTipText = "Portal Digger"; - this.toolPortalDigger.Click += new System.EventHandler(this.toolPortalDigger_Click); - // - // toolUVFixer - // - this.toolUVFixer.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(60)))), ((int)(((byte)(63)))), ((int)(((byte)(65))))); - this.toolUVFixer.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Image; - this.toolUVFixer.ForeColor = System.Drawing.Color.FromArgb(((int)(((byte)(220)))), ((int)(((byte)(220)))), ((int)(((byte)(220))))); - this.toolUVFixer.Image = global::TombEditor.Properties.Resources.toolbox_UVFixer_16; - this.toolUVFixer.ImageTransparentColor = System.Drawing.Color.Magenta; - this.toolUVFixer.Margin = new System.Windows.Forms.Padding(1); - this.toolUVFixer.Name = "toolUVFixer"; - this.toolUVFixer.Size = new System.Drawing.Size(23, 20); - this.toolUVFixer.Text = "toolStripButton1"; - this.toolUVFixer.ToolTipText = "Fix texture coordinates"; - this.toolUVFixer.Click += new System.EventHandler(this.toolUVFixer_Click); + _elementHost.Dock = System.Windows.Forms.DockStyle.Fill; + _elementHost.Name = "_elementHost"; + _elementHost.Child = _toolBoxView; // // ToolBox // - this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F); - this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; - this.AutoSizeMode = System.Windows.Forms.AutoSizeMode.GrowAndShrink; - this.Controls.Add(this.toolStrip); - this.Margin = new System.Windows.Forms.Padding(0); - this.Name = "ToolBox"; - this.Size = new System.Drawing.Size(28, 453); - this.toolStrip.ResumeLayout(false); - this.toolStrip.PerformLayout(); - this.ResumeLayout(false); - + AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F); + AutoScaleMode = System.Windows.Forms.AutoScaleMode.Dpi; + AutoSizeMode = System.Windows.Forms.AutoSizeMode.GrowAndShrink; + Controls.Add(_elementHost); + Padding = new System.Windows.Forms.Padding(0); + Margin = new System.Windows.Forms.Padding(0); + Name = "ToolBox"; + Size = new System.Drawing.Size(35, 599); + ResumeLayout(false); } #endregion - private DarkUI.Controls.DarkToolStrip toolStrip; - private System.Windows.Forms.ToolStripButton toolSelection; - private System.Windows.Forms.ToolStripButton toolBrush; - private System.Windows.Forms.ToolStripButton toolShovel; - private System.Windows.Forms.ToolStripButton toolPencil; - private System.Windows.Forms.ToolStripButton toolFlatten; - private System.Windows.Forms.ToolStripButton toolSmooth; - private System.Windows.Forms.ToolStripButton toolFill; - private System.Windows.Forms.ToolStripButton toolGroup; - private System.Windows.Forms.ToolStripSeparator toolSeparator1; - private System.Windows.Forms.ToolStripButton toolDrag; - private System.Windows.Forms.ToolStripButton toolRamp; - private System.Windows.Forms.ToolStripButton toolQuarterPipe; - private System.Windows.Forms.ToolStripButton toolHalfPipe; - private System.Windows.Forms.ToolStripButton toolBowl; - private System.Windows.Forms.ToolStripButton toolPyramid; - private System.Windows.Forms.ToolStripButton toolTerrain; - private System.Windows.Forms.ToolStripButton toolEraser; - private System.Windows.Forms.ToolStripButton toolInvisibility; - private System.Windows.Forms.ToolStripSeparator toolSeparator2; - private System.Windows.Forms.ToolStripButton toolUVFixer; - private System.Windows.Forms.ToolStripButton toolGridPaint; - private System.Windows.Forms.ToolStripButton toolPortalDigger; + private System.Windows.Forms.Integration.ElementHost _elementHost; + private Views.ToolBoxView _toolBoxView; } } diff --git a/TombEditor/Controls/ToolBox.cs b/TombEditor/Controls/ToolBox.cs index 149f2adad1..800e2a4eab 100644 --- a/TombEditor/Controls/ToolBox.cs +++ b/TombEditor/Controls/ToolBox.cs @@ -1,8 +1,5 @@ using System; -using System.ComponentModel; using System.Windows.Forms; -using TombEditor.Controls.ContextMenus; -using TombLib.Utils; namespace TombEditor.Controls { @@ -10,247 +7,55 @@ public partial class ToolBox : UserControl { public ToolStripLayoutStyle LayoutStyle { - get { return toolStrip.LayoutStyle; } + get => _layoutStyle; set { - if (value == toolStrip.LayoutStyle) + if (value == _layoutStyle) return; - else - { - toolStrip.LayoutStyle = value; - if (value == ToolStripLayoutStyle.Flow) - toolStrip.Dock = DockStyle.Fill; - else - { - toolStrip.Dock = DockStyle.None; - toolStrip.AutoSize = true; - } - } - } - } - - private readonly Editor _editor; - private Timer _contextMenuTimer; - - public ToolBox() - { - InitializeComponent(); - - _contextMenuTimer = new Timer(); - _contextMenuTimer.Interval = 300; - _contextMenuTimer.Tick += ContextMenuTimer_Tick; - - - if (LicenseManager.UsageMode == LicenseUsageMode.Runtime) - { - _editor = Editor.Instance; - _editor.EditorEventRaised += EditorEventRaised; - } - } - - private void EditorEventRaised(IEditorEvent obj) - { - if (obj is Editor.ToolChangedEvent || obj is Editor.InitEvent) - { - EditorTool currentTool = _editor.Tool; - - toolSelection.Checked = currentTool.Tool == EditorToolType.Selection; - toolBrush.Checked = currentTool.Tool == EditorToolType.Brush; - toolPencil.Checked = currentTool.Tool == EditorToolType.Pencil; - toolFill.Checked = currentTool.Tool == EditorToolType.Fill; - toolGroup.Checked = currentTool.Tool == EditorToolType.Group; - toolGridPaint.Checked = currentTool.Tool == EditorToolType.GridPaint; - toolShovel.Checked = currentTool.Tool == EditorToolType.Shovel; - toolFlatten.Checked = currentTool.Tool == EditorToolType.Flatten; - toolSmooth.Checked = currentTool.Tool == EditorToolType.Smooth; - toolDrag.Checked = currentTool.Tool == EditorToolType.Drag; - toolRamp.Checked = currentTool.Tool == EditorToolType.Ramp; - toolQuarterPipe.Checked = currentTool.Tool == EditorToolType.QuarterPipe; - toolHalfPipe.Checked = currentTool.Tool == EditorToolType.HalfPipe; - toolBowl.Checked = currentTool.Tool == EditorToolType.Bowl; - toolPyramid.Checked = currentTool.Tool == EditorToolType.Pyramid; - toolTerrain.Checked = currentTool.Tool == EditorToolType.Terrain; - toolPortalDigger.Checked = currentTool.Tool == EditorToolType.PortalDigger; - - toolUVFixer.Checked = currentTool.TextureUVFixer; - - switch(currentTool.GridSize) - { - case PaintGridSize.Grid2x2: - toolGridPaint.Image = Properties.Resources.toolbox_GridPaint2x2_16; - toolGridPaint.ToolTipText = "Grid Paint (2x2)"; - break; - case PaintGridSize.Grid3x3: - toolGridPaint.Image = Properties.Resources.toolbox_GridPaint3x3_16; - toolGridPaint.ToolTipText = "Grid Paint (3x3)"; - break; - case PaintGridSize.Grid4x4: - toolGridPaint.Image = Properties.Resources.toolbox_GridPaint4x4_16; - toolGridPaint.ToolTipText = "Grid Paint (4x4)"; - break; - } - } - - if (obj is Editor.SelectedTexturesChangedEvent || obj is Editor.InitEvent) - { - toolEraser.Checked = _editor.SelectedTexture.Texture == null; - toolInvisibility.Checked = _editor.SelectedTexture.Texture is TextureInvisible; - } - if (obj is Editor.ModeChangedEvent || obj is Editor.InitEvent) - { - EditorMode mode = _editor.Mode; - bool geometryMode = mode == EditorMode.Geometry; + _layoutStyle = value; - toolFill.Visible = !geometryMode; - toolGroup.Visible = !geometryMode; - toolGridPaint.Visible = !geometryMode; - toolEraser.Visible = !geometryMode; - toolInvisibility.Visible = !geometryMode; - toolUVFixer.Visible = !geometryMode; - toolFlatten.Visible = geometryMode; - toolShovel.Visible = geometryMode; - toolSmooth.Visible = geometryMode; - toolDrag.Visible = geometryMode; - toolRamp.Visible = geometryMode; - toolQuarterPipe.Visible = geometryMode; - toolHalfPipe.Visible = geometryMode; - toolBowl.Visible = geometryMode; - toolPyramid.Visible = geometryMode; - toolTerrain.Visible = geometryMode; - toolPortalDigger.Visible = geometryMode; + if (value == ToolStripLayoutStyle.Flow) + _toolBoxView.PanelOrientation = System.Windows.Controls.Orientation.Horizontal; + else + _toolBoxView.PanelOrientation = System.Windows.Controls.Orientation.Vertical; - toolStrip.AutoSize = true; - AutoSize = true; - toolStrip.Visible = mode == EditorMode.FaceEdit || mode == EditorMode.Lighting || mode == EditorMode.Geometry; + _toolBoxView.RequestHeightUpdate(); } } - private void ContextMenuTimer_Tick(object sender, EventArgs e) - { - var _currentContextMenu = new GridPaintContextMenu(_editor, this); - _currentContextMenu.Show(Cursor.Position); - _contextMenuTimer.Stop(); - } - - private void SwitchTool(EditorToolType tool) - { - EditorTool currentTool = new EditorTool() { Tool = tool, TextureUVFixer = _editor.Tool.TextureUVFixer, GridSize = _editor.Tool.GridSize }; - _editor.Tool = currentTool; - } - - private void toolSelection_Click(object sender, EventArgs e) - { - SwitchTool(EditorToolType.Selection); - } - - private void toolBrush_Click(object sender, EventArgs e) - { - SwitchTool(EditorToolType.Brush); - } + private ToolStripLayoutStyle _layoutStyle = ToolStripLayoutStyle.VerticalStackWithOverflow; + private int _lastMeasuredWidth = -1; - private void toolPencil_Click(object sender, EventArgs e) - { - SwitchTool(EditorToolType.Pencil); - } - - private void toolShovel_Click(object sender, EventArgs e) - { - SwitchTool(EditorToolType.Shovel); - } - - private void toolFlatten_Click(object sender, EventArgs e) - { - SwitchTool(EditorToolType.Flatten); - } - - private void toolFill_Click(object sender, EventArgs e) - { - SwitchTool(EditorToolType.Fill); - } - - private void toolSmooth_Click(object sender, EventArgs e) - { - SwitchTool(EditorToolType.Smooth); - } - - private void toolGroup_Click(object sender, EventArgs e) - { - SwitchTool(EditorToolType.Group); - } - - private void toolDrag_Click(object sender, EventArgs e) - { - SwitchTool(EditorToolType.Drag); - } - - private void toolRamp_Click(object sender, EventArgs e) - { - SwitchTool(EditorToolType.Ramp); - } - - private void toolQuarterPipe_Click(object sender, EventArgs e) - { - SwitchTool(EditorToolType.QuarterPipe); - } - - private void toolHalfPipe_Click(object sender, EventArgs e) - { - SwitchTool(EditorToolType.HalfPipe); - } - - private void toolBowl_Click(object sender, EventArgs e) - { - SwitchTool(EditorToolType.Bowl); - } - - private void toolPyramid_Click(object sender, EventArgs e) - { - SwitchTool(EditorToolType.Pyramid); - } - - private void toolTerrain_Click(object sender, EventArgs e) - { - SwitchTool(EditorToolType.Terrain); - } - - private void tooPaint2x2_Click(object sender, EventArgs e) - { - SwitchTool(EditorToolType.GridPaint); - } - - private void toolPortalDigger_Click(object sender, EventArgs e) - { - SwitchTool(EditorToolType.PortalDigger); - } - - private void toolInvisibility_Click(object sender, EventArgs e) + public ToolBox() { - _editor.SelectedTexture = TextureArea.Invisible; + InitializeComponent(); + _toolBoxView.SetWinFormsHost(_elementHost); + _toolBoxView.PreferredHeightChanged += OnPreferredHeightChanged; + _toolBoxView.PreferredWidthChanged += OnPreferredWidthChanged; } - private void toolEraser_Click(object sender, EventArgs e) + private void OnPreferredHeightChanged(int preferredHeight) { - _editor.SelectedTexture = TextureArea.None; + if (Height != preferredHeight) + Height = preferredHeight; } - private void toolUVFixer_Click(object sender, EventArgs e) + private void OnPreferredWidthChanged(int preferredWidth) { - EditorTool currentTool = new EditorTool() { Tool = _editor.Tool.Tool, TextureUVFixer = !_editor.Tool.TextureUVFixer, GridSize = _editor.Tool.GridSize }; - _editor.Tool = currentTool; + if (preferredWidth > 0 && Width != preferredWidth) + Width = preferredWidth; } - private void toolGridPaint_MouseUp(object sender, MouseEventArgs e) + protected override void OnSizeChanged(EventArgs e) { - _contextMenuTimer.Stop(); - } + base.OnSizeChanged(e); - private void toolGridPaint_MouseDown(object sender, MouseEventArgs e) - { - if (e.Button != MouseButtons.Right) - _contextMenuTimer.Start(); - else - ContextMenuTimer_Tick(sender, e); + if (Width != _lastMeasuredWidth) + { + _lastMeasuredWidth = Width; + _toolBoxView?.RequestHeightUpdate(); + } } } } diff --git a/TombEditor/Controls/ToolBox.resx b/TombEditor/Controls/ToolBox.resx index c42befb074..7c1710f255 100644 --- a/TombEditor/Controls/ToolBox.resx +++ b/TombEditor/Controls/ToolBox.resx @@ -1,17 +1,17 @@  - @@ -120,4 +120,7 @@ 17, 17 + + 25 + \ No newline at end of file diff --git a/TombEditor/Controls/TriggerManager.cs b/TombEditor/Controls/TriggerManager.cs index 4e8b890ffd..5205948e2c 100644 --- a/TombEditor/Controls/TriggerManager.cs +++ b/TombEditor/Controls/TriggerManager.cs @@ -45,6 +45,7 @@ public Event Event private Editor _editor; private bool _lockUI = false; + private bool _nodeListRefreshPending = false; private void EditorEventRaised(IEditorEvent obj) { @@ -62,8 +63,16 @@ obj is Editor.EventSetsChangedEvent || (obj is Editor.ObjectChangedEvent && (obj as Editor.ObjectChangedEvent).ChangeType != ObjectChangeType.Change)) { - nodeEditor.PopulateCachedNodeLists(_editor.Level); - nodeEditor.RefreshArgumentUI(); + if (!_nodeListRefreshPending && IsHandleCreated) + { + _nodeListRefreshPending = true; + BeginInvoke((Action)(() => + { + _nodeListRefreshPending = false; + nodeEditor.PopulateCachedNodeLists(_editor.Level); + nodeEditor.RefreshArgumentUI(); + })); + } } } diff --git a/TombEditor/Editor.cs b/TombEditor/Editor.cs index f35d8eaa31..dc56768c27 100644 --- a/TombEditor/Editor.cs +++ b/TombEditor/Editor.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Numerics; using System.Threading; using System.Threading.Tasks; using TombLib; @@ -11,6 +12,7 @@ using TombLib.LevelData.IO; using TombLib.Rendering; using TombLib.Utils; +using TombLib.Wad; using TombLib.Wad.Catalog; namespace TombEditor @@ -90,8 +92,7 @@ public Level Level // Reset state that was related to the old level _levelSettingsWatcher?.StopReloading(); SelectedObject = null; - ChosenItem = null; - ChosenImportedGeometry = null; + ChosenItems = Array.Empty(); SelectedSectors = SectorSelection.None; Action = null; SelectedTexture = TextureArea.None; @@ -152,42 +153,41 @@ public IEditorAction Action } } - public class ChosenItemChangedEvent : IEditorPropertyChangedEvent + public class ChosenItemsChangedEvent : IEditorPropertyChangedEvent { - public ItemType? Previous { get; internal set; } - public ItemType? Current { get; internal set; } + public IReadOnlyList Previous { get; internal set; } + public IReadOnlyList Current { get; internal set; } } - private ItemType? _chosenItem; - public ItemType? ChosenItem + + private IWadObject[] _chosenItems = Array.Empty(); + public IReadOnlyList ChosenItems { - get { return _chosenItem; } + get { return _chosenItems; } set { - if (value == _chosenItem) + var arr = value?.ToArray() ?? Array.Empty(); + if (_chosenItems.SequenceEqual(arr)) return; - var previous = _chosenItem; - _chosenItem = value; - RaiseEvent(new ChosenItemChangedEvent { Previous = previous, Current = value }); + + var previous = _chosenItems; + _chosenItems = arr; + + RaiseEvent(new ChosenItemsChangedEvent { Previous = previous, Current = arr }); } } - public class ChosenImportedGeometryChangedEvent : IEditorPropertyChangedEvent - { - public ImportedGeometry Previous { get; internal set; } - public ImportedGeometry Current { get; internal set; } - } - private ImportedGeometry _chosenImportedGeometry = null; - public ImportedGeometry ChosenImportedGeometry + public IWadObject GetFirstWadObject() { - get { return _chosenImportedGeometry; } - set + if (ChosenItems == null || ChosenItems.Count == 0) + return null; + + foreach (var obj in ChosenItems) { - if (value == _chosenImportedGeometry) - return; - var previous = _chosenImportedGeometry; - _chosenImportedGeometry = value; - RaiseEvent(new ChosenImportedGeometryChangedEvent { Previous = previous, Current = value }); + if (obj is WadMoveable || obj is WadStatic) + return obj; } + + return null; } public class ModeChangedEvent : IEditorPropertyChangedEvent @@ -229,6 +229,15 @@ public EditorTool Tool } private EditorTool _lastGeometryTool = new EditorTool(); private EditorTool _lastFaceEditTool = new EditorTool(); + private EditorTool _lastObjectPlacementTool = new EditorTool(); + + // Raised when brush settings (radius, density, rotation, etc.) change in the toolbox. + // Only Panel3D needs to respond, so this avoids the heavier ConfigurationChangedEvent. + public class ObjectBrushSettingsChangedEvent : IEditorEvent { } + public void ObjectBrushSettingsChange() + { + RaiseEvent(new ObjectBrushSettingsChangedEvent()); + } public LastSelectionType LastSelection = LastSelectionType.None; @@ -1019,6 +1028,7 @@ private void Editor_EditorEventRaised(IEditorEvent obj) // Update tools _lastFaceEditTool = Configuration.UI_LastTexturingTool; _lastGeometryTool = Configuration.UI_LastGeometryTool; + _lastObjectPlacementTool = Configuration.UI_LastObjectPlacementTool; if (Mode == EditorMode.Geometry) Tool = Configuration.UI_LastGeometryTool; @@ -1074,8 +1084,17 @@ obj is RoomTextureChangedEvent || if (@event.Current == EditorMode.Geometry) Tool = _lastGeometryTool; + else if (@event.Current == EditorMode.ObjectPlacement) + Tool = _lastObjectPlacementTool; else Tool = _lastFaceEditTool; + + // If the mode switched to lighting mode, relight all rooms which have `PendingRelight` set to true + if (@event.Current == EditorMode.Lighting) + { + Parallel.ForEach(Level.Rooms.Where(room => room?.PendingRelight == true), + room => room.RebuildLighting(Configuration.Rendering3D_HighQualityLightPreview)); + } } // Backup last used tool for next mode @@ -1087,6 +1106,11 @@ obj is RoomTextureChangedEvent || _lastGeometryTool = @event.Current; Configuration.UI_LastGeometryTool = _lastGeometryTool; } + else if (Mode == EditorMode.ObjectPlacement) + { + _lastObjectPlacementTool = @event.Current; + Configuration.UI_LastObjectPlacementTool = _lastObjectPlacementTool; + } else { _lastFaceEditTool = @event.Current; @@ -1449,5 +1473,7 @@ public bool IsPreciseGeometryAllowed => Level.Settings.GameVersion is TRVersion.Game.TombEngine || Configuration.Editor_EnableStepHeightControlsForUnsupportedEngines; public int IncrementReference => IsPreciseGeometryAllowed ? Configuration.Editor_StepHeight : Level.FullClickHeight; + + public bool ShouldRelight => Mode is EditorMode.Lighting; } } diff --git a/TombEditor/EditorActions.cs b/TombEditor/EditorActions.cs index b3ea287e37..dcdc02083f 100644 --- a/TombEditor/EditorActions.cs +++ b/TombEditor/EditorActions.cs @@ -68,7 +68,7 @@ public static void SmartBuildGeometry(Room room, RectangleInt2 area) var watch = new Stopwatch(); watch.Start(); - room.SmartBuildGeometry(area, _editor.Configuration.Rendering3D_HighQualityLightPreview); + room.SmartBuildGeometry(area, _editor.ShouldRelight, _editor.Configuration.Rendering3D_HighQualityLightPreview); watch.Stop(); logger.Debug("Edit geometry time: " + watch.ElapsedMilliseconds + " ms"); _editor.RoomGeometryChange(room); @@ -683,6 +683,7 @@ public static void AddTrigger(Room room, RectangleInt2 area, IWin32Window owner, objectName = (o as MoveableInstance).WadObjectId.ShortName(_editor.Level.Settings.GameVersion).ToLower(); bool isSwitch = objectName.Contains("switch") || objectName.Contains("pulley"); + bool isHeavy = objectName.Contains("fusebox") || objectName.Contains("electrical switch box"); // TR3/TEN Fusebox bool isHole = objectName.Contains("hole") && (objectName.Contains("key") || objectName.Contains("puzzle")); bool isBridge = objectName.Contains("bridge") && @@ -696,6 +697,8 @@ public static void AddTrigger(Room room, RectangleInt2 area, IWin32Window owner, trigger.TriggerType = TriggerType.Switch; else if (isBridge) trigger.TriggerType = TriggerType.Dummy; + else if (isHeavy) + trigger.TriggerType = TriggerType.Heavy; else return false; @@ -1326,27 +1329,33 @@ public static void DeleteObjectWithoutUpdate(ObjectInstance instance) if (instance is LightInstance) { - room.RebuildLighting(_editor.Configuration.Rendering3D_HighQualityLightPreview); + if (_editor.ShouldRelight) + room.RebuildLighting(_editor.Configuration.Rendering3D_HighQualityLightPreview); + else + room.PendingRelight = true; + _editor.RoomGeometryChange(room); } if (instance is PortalInstance) { - room.BuildGeometry(_editor.Configuration.Rendering3D_HighQualityLightPreview); + room.Rebuild(_editor.ShouldRelight, _editor.Configuration.Rendering3D_HighQualityLightPreview); + if (adjoiningRoom != null) { - adjoiningRoom.BuildGeometry(_editor.Configuration.Rendering3D_HighQualityLightPreview); + adjoiningRoom.Rebuild(_editor.ShouldRelight, _editor.Configuration.Rendering3D_HighQualityLightPreview); _editor.RoomSectorPropertiesChange(adjoiningRoom); if (adjoiningRoom.AlternateOpposite != null) { - adjoiningRoom.AlternateOpposite.BuildGeometry(_editor.Configuration.Rendering3D_HighQualityLightPreview); + adjoiningRoom.AlternateOpposite.Rebuild(_editor.ShouldRelight, _editor.Configuration.Rendering3D_HighQualityLightPreview); _editor.RoomSectorPropertiesChange(adjoiningRoom.AlternateOpposite); } } + if (room.AlternateOpposite != null) { - room.AlternateOpposite.BuildGeometry(_editor.Configuration.Rendering3D_HighQualityLightPreview); + room.AlternateOpposite.Rebuild(_editor.ShouldRelight, _editor.Configuration.Rendering3D_HighQualityLightPreview); _editor.RoomSectorPropertiesChange(room.AlternateOpposite); } } @@ -1528,7 +1537,9 @@ public static List> FindTextures(TextureSearchTyp break; case TextureSearchType.Broken: - if (tex.TriangleCoordsOutOfBounds || tex.QuadCoordsOutOfBounds) + float maxTexCoordSpan = _editor.Level?.IsTombEngine == true ? 1024.0f : 256.0f; + + if (tex.AreTriangleCoordsOutOfBounds(maxTexCoordSpan) || tex.AreQuadCoordsOutOfBounds(maxTexCoordSpan)) result.Add(entry); if (!tex.TextureIsInvisible) @@ -2335,6 +2346,41 @@ private static void AllocateScriptIds(PositionBasedObjectInstance instance) AllocateScriptIds(obj); } + // Batch-optimized version that allocates script IDs in bulk. + public static void AllocateScriptIds(IEnumerable instances) + { + if (_editor.Level.IsTombEngine) + { + var existingNames = _editor.Level.GetAllLuaNames(); + foreach (var instance in instances) + AllocateScriptIdsWithCache(instance, existingNames); + } + else + { + foreach (var instance in instances) + AllocateScriptIds(instance); + } + } + + private static void AllocateScriptIdsWithCache(PositionBasedObjectInstance instance, HashSet luaNameCache) + { + if (instance is IHasScriptID && (_editor.Level.Settings.GameVersion == TRVersion.Game.TR4 || _editor.Level.IsNG)) + { + var si = instance as IHasScriptID; + if (si.ScriptId == null) + si.AllocateNewScriptId(); + } + else if (instance is PositionAndScriptBasedObjectInstance scriptObj && _editor.Level.IsTombEngine) + { + if (string.IsNullOrEmpty(scriptObj.LuaName)) + scriptObj.AllocateNewLuaName(luaNameCache); + } + + if (instance is ObjectGroup group) + foreach (var obj in group) + AllocateScriptIdsWithCache(obj, luaNameCache); + } + public static void PlaceLight(LightType type) { var color = (type == LightType.FogBulb && _editor.Level.Settings.GameVersion.Native() <= TRVersion.Game.TR4) ? @@ -2558,8 +2604,8 @@ public static void DeleteRooms(IEnumerable rooms_, IWin32Window owner = nu // Update selection foreach (Room adjoiningRoom in adjoiningRooms) { - adjoiningRoom?.BuildGeometry(_editor.Configuration.Rendering3D_HighQualityLightPreview); - adjoiningRoom?.AlternateOpposite?.BuildGeometry(_editor.Configuration.Rendering3D_HighQualityLightPreview); + adjoiningRoom?.Rebuild(_editor.ShouldRelight, _editor.Configuration.Rendering3D_HighQualityLightPreview); + adjoiningRoom?.AlternateOpposite?.Rebuild(_editor.ShouldRelight, _editor.Configuration.Rendering3D_HighQualityLightPreview); } // Select last room, if available. Else select first existing room. @@ -2608,7 +2654,7 @@ public static void CropRoom(Room room, RectangleInt2 newArea, IWin32Window owner Room.FixupNeighborPortals(_editor.Level, new[] { room }, new[] { room }, ref relevantRooms); Parallel.ForEach(relevantRooms, relevantRoom => { - relevantRoom.BuildGeometry(_editor.Configuration.Rendering3D_HighQualityLightPreview); + relevantRoom.Rebuild(_editor.ShouldRelight, _editor.Configuration.Rendering3D_HighQualityLightPreview); }); // Cleanup @@ -3123,7 +3169,7 @@ public static void AddPortal(Room room, RectangleInt2 area, IWin32Window owner) // Update foreach (Room portalRoom in portals.Select(portal => portal.Room).Distinct()) { - portalRoom.BuildGeometry(_editor.Configuration.Rendering3D_HighQualityLightPreview); + portalRoom.Rebuild(_editor.ShouldRelight, _editor.Configuration.Rendering3D_HighQualityLightPreview); } foreach (PortalInstance portal in portals) @@ -3151,7 +3197,7 @@ public static void AlternateRoomEnable(Room room, short AlternateGroup) newRoom.Properties.Locked = false; newRoom.Name = room + " (Flipped)"; - newRoom.BuildGeometry(_editor.Configuration.Rendering3D_HighQualityLightPreview); + newRoom.Rebuild(_editor.ShouldRelight, _editor.Configuration.Rendering3D_HighQualityLightPreview); // Assign room _editor.Level.AssignRoomToFree(newRoom); @@ -3447,7 +3493,7 @@ public static void GridWallsSquares(Room room, RectangleInt2 area, bool fiveDivi if (fromUI) SmartBuildGeometry(room, area); else - room.BuildGeometry(); + room.Rebuild(_editor.ShouldRelight, _editor.Configuration.Rendering3D_HighQualityLightPreview); } public static Room CreateAdjoiningRoom(Room room, SectorSelection selection, PortalDirection direction, bool grid, int roomDepth, bool switchRoom = true, bool clearAdjoiningArea = false) @@ -3603,10 +3649,10 @@ public static Room CreateAdjoiningRoom(Room room, SectorSelection selection, Por // Build the geometry of the new room Parallel.Invoke(() => { - newRoom.BuildGeometry(_editor.Configuration.Rendering3D_HighQualityLightPreview); + newRoom.Rebuild(_editor.ShouldRelight, _editor.Configuration.Rendering3D_HighQualityLightPreview); }, () => { - room.BuildGeometry(_editor.Configuration.Rendering3D_HighQualityLightPreview); + room.Rebuild(_editor.ShouldRelight, _editor.Configuration.Rendering3D_HighQualityLightPreview); }); if (switchRoom && (_editor.SelectedRoom == room || _editor.SelectedRoom == room.AlternateOpposite)) @@ -3917,7 +3963,7 @@ public static void MergeRoomsHorizontally(IEnumerable rooms, IWin32Window Room.FixupNeighborPortals(_editor.Level, new[] { newRoom }, new[] { newRoom }.Concat(mergeRooms), ref relevantRooms); Parallel.ForEach(relevantRooms, relevantRoom => { - relevantRoom.BuildGeometry(_editor.Configuration.Rendering3D_HighQualityLightPreview); + relevantRoom.Rebuild(_editor.ShouldRelight, _editor.Configuration.Rendering3D_HighQualityLightPreview); }); // Add room and update the editor @@ -3968,7 +4014,7 @@ public static void SplitRoom(IWin32Window owner) Room.FixupNeighborPortals(_editor.Level, new[] { room, splitRoom }, new[] { room, splitRoom }, ref relevantRooms); Parallel.ForEach(relevantRooms, relevantRoom => { - relevantRoom.BuildGeometry(_editor.Configuration.Rendering3D_HighQualityLightPreview); + relevantRoom.Rebuild(_editor.ShouldRelight, _editor.Configuration.Rendering3D_HighQualityLightPreview); }); // Cleanup @@ -4015,7 +4061,7 @@ public static void DuplicateRoom(IWin32Window owner) var newRoom = _editor.SelectedRoom.Clone(_editor.Level); newRoom.Name = cutName + " (copy" + buffer + ")"; - newRoom.BuildGeometry(_editor.Configuration.Rendering3D_HighQualityLightPreview); + newRoom.Rebuild(_editor.ShouldRelight, _editor.Configuration.Rendering3D_HighQualityLightPreview); _editor.Level.AssignRoomToFree(newRoom); _editor.RoomListChange(); _editor.UndoManager.PushRoomCreated(newRoom); @@ -4733,7 +4779,7 @@ public static bool TransformRooms(RectTransformation transformation, IWin32Windo var newRooms = _editor.Level.TransformRooms(_editor.SelectedRooms, transformation); foreach (Room room in newRooms) { - room.BuildGeometry(_editor.Configuration.Rendering3D_HighQualityLightPreview); + room.Rebuild(_editor.ShouldRelight, _editor.Configuration.Rendering3D_HighQualityLightPreview); } _editor.SelectRoomsAndResetCamera(newRooms); @@ -4836,9 +4882,13 @@ public static void TryPasteSectors(SectorsClipboardData data, IWin32Window owner } // Redraw rooms in portals - portals.Select(p => p.AdjoiningRoom).ToList().ForEach(room => { room.BuildGeometry(); _editor.RoomGeometryChange(room); }); + portals.Select(p => p.AdjoiningRoom).ToList().ForEach(room => + { + room.Rebuild(_editor.ShouldRelight, _editor.Configuration.Rendering3D_HighQualityLightPreview); + _editor.RoomGeometryChange(room); + }); - _editor.SelectedRoom.BuildGeometry(_editor.Configuration.Rendering3D_HighQualityLightPreview); + _editor.SelectedRoom.Rebuild(_editor.ShouldRelight, _editor.Configuration.Rendering3D_HighQualityLightPreview); _editor.RoomSectorPropertiesChange(_editor.SelectedRoom); } @@ -4938,7 +4988,7 @@ public static void SetPortalOpacity(PortalOpacity opacity, IWin32Window owner) } portal.Opacity = opacity; - portal.Room.BuildGeometry(_editor.Configuration.Rendering3D_HighQualityLightPreview); + portal.Room.Rebuild(_editor.ShouldRelight, _editor.Configuration.Rendering3D_HighQualityLightPreview); _editor.RoomGeometryChange(portal.Room); _editor.ObjectChange(portal, ObjectChangeType.Change); } @@ -5003,10 +5053,16 @@ public static bool SaveLevel(IWin32Window owner, bool askForPath) public static ItemType? GetCurrentItemWithMessage() { - ItemType? result = _editor.ChosenItem; - if (result == null) - _editor.SendMessage("Select an item first.", PopupType.Error); - return result; + foreach (var obj in _editor.ChosenItems) + { + if (obj is WadMoveable m) + return new ItemType(m.Id); + if (obj is WadStatic s) + return new ItemType(s.Id); + } + + _editor.SendMessage("Select an item first.", PopupType.Error); + return null; } public static void FindItem() @@ -5454,7 +5510,7 @@ public static void MoveRooms(VectorInt3 positionDelta, IEnumerable rooms, // Update foreach (Room room in roomsToUpdate) { - room.BuildGeometry(_editor.Configuration.Rendering3D_HighQualityLightPreview); + room.Rebuild(_editor.ShouldRelight, _editor.Configuration.Rendering3D_HighQualityLightPreview); _editor.RoomSectorPropertiesChange(room); } @@ -5514,7 +5570,7 @@ public static void SplitSectorObjectOnSelection(SectorBasedObjectInstance @objec Room.FixupNeighborPortals(_editor.Level, new[] { room }, new[] { room }, ref relevantRooms); Parallel.ForEach(relevantRooms, relevantRoom => { - relevantRoom.BuildGeometry(_editor.Configuration.Rendering3D_HighQualityLightPreview); + relevantRoom.Rebuild(_editor.ShouldRelight, _editor.Configuration.Rendering3D_HighQualityLightPreview); }); foreach (Room relevantRoom in relevantRooms) _editor.RoomPropertiesChange(relevantRoom); diff --git a/TombEditor/EditorCommands.cs b/TombEditor/EditorCommands.cs index a2c624dc2a..933709f0c6 100644 --- a/TombEditor/EditorCommands.cs +++ b/TombEditor/EditorCommands.cs @@ -129,6 +129,11 @@ private void AddCommands() EditorActions.SwitchMode(EditorMode.Lighting); }); + AddCommand("SwitchObjectPlacementMode", "Switch to Object Placement mode", CommandType.General, delegate () + { + EditorActions.SwitchMode(EditorMode.ObjectPlacement); + }); + AddCommand("ResetCamera", "Reset camera position to default", CommandType.View, delegate () { _editor.ResetCamera(); @@ -1126,6 +1131,7 @@ public static List GenerateDefaultHotkeys() new HotkeySet { Name = "SwitchGeometryMode", Hotkeys = new List { (uint)(Keys.F2) } }, new HotkeySet { Name = "SwitchFaceEditMode", Hotkeys = new List { (uint)(Keys.F3) } }, new HotkeySet { Name = "SwitchLightingMode", Hotkeys = new List { (uint)(Keys.F4) } }, + new HotkeySet { Name = "SwitchObjectPlacementMode", Hotkeys = new List { (uint)(Keys.F5) } }, new HotkeySet { Name = "ResetCamera", Hotkeys = new List { (uint)(Keys.F6) } }, new HotkeySet { Name = "AddTrigger", Hotkeys = new List { (uint)(Keys.T) } }, new HotkeySet { Name = "AddPortal", Hotkeys = new List { (uint)(Keys.P) } }, diff --git a/TombEditor/EditorState.cs b/TombEditor/EditorState.cs index 3261eb82dd..9ab8b77977 100644 --- a/TombEditor/EditorState.cs +++ b/TombEditor/EditorState.cs @@ -7,7 +7,7 @@ namespace TombEditor { public enum EditorMode { - Geometry, Map2D, FaceEdit, Lighting + Geometry, Map2D, FaceEdit, Lighting, ObjectPlacement } public enum EditorToolType @@ -15,7 +15,8 @@ public enum EditorToolType None, Selection, Brush, Pencil, Fill, Group, GridPaint, Shovel, Smooth, Flatten, - Drag, Ramp, QuarterPipe, HalfPipe, Bowl, Pyramid, Terrain, PortalDigger /* Do not modify enum order after drag tool! */ + Drag, Ramp, QuarterPipe, HalfPipe, Bowl, Pyramid, Terrain, PortalDigger, /* Do not modify enum order after drag tool! */ + Line, Eraser, ObjectSelection, ObjectDeselection } public enum PaintGridSize @@ -23,6 +24,11 @@ public enum PaintGridSize Grid2x2, Grid3x3, Grid4x4 } + public enum ObjectBrushShape + { + Circle, Square + } + public class EditorTool { public EditorToolType Tool { get; set; } = EditorToolType.Selection; diff --git a/TombEditor/Forms/FormMain.Designer.cs b/TombEditor/Forms/FormMain.Designer.cs index 21678c7455..03c0b78590 100644 --- a/TombEditor/Forms/FormMain.Designer.cs +++ b/TombEditor/Forms/FormMain.Designer.cs @@ -197,6 +197,7 @@ private void InitializeComponent() roomOptionsToolStripMenuItem = new ToolStripMenuItem(); itemBrowserToolStripMenuItem = new ToolStripMenuItem(); importedGeometryBrowserToolstripMenuItem = new ToolStripMenuItem(); + contentBrowserToolStripMenuItem = new ToolStripMenuItem(); triggerListToolStripMenuItem = new ToolStripMenuItem(); lightingToolStripMenuItem = new ToolStripMenuItem(); paletteToolStripMenuItem = new ToolStripMenuItem(); @@ -1866,7 +1867,7 @@ private void InitializeComponent() // windowToolStripMenuItem // windowToolStripMenuItem.BackColor = System.Drawing.Color.FromArgb(60, 63, 65); - windowToolStripMenuItem.DropDownItems.AddRange(new ToolStripItem[] { restoreDefaultLayoutToolStripMenuItem, toolStripMenuSeparator14, sectorOptionsToolStripMenuItem, roomOptionsToolStripMenuItem, itemBrowserToolStripMenuItem, importedGeometryBrowserToolstripMenuItem, triggerListToolStripMenuItem, lightingToolStripMenuItem, paletteToolStripMenuItem, texturePanelToolStripMenuItem, objectListToolStripMenuItem, statisticsToolStripMenuItem, dockableToolStripMenuItem, floatingToolStripMenuItem }); + windowToolStripMenuItem.DropDownItems.AddRange(new ToolStripItem[] { restoreDefaultLayoutToolStripMenuItem, toolStripMenuSeparator14, sectorOptionsToolStripMenuItem, roomOptionsToolStripMenuItem, itemBrowserToolStripMenuItem, importedGeometryBrowserToolstripMenuItem, contentBrowserToolStripMenuItem, triggerListToolStripMenuItem, lightingToolStripMenuItem, paletteToolStripMenuItem, texturePanelToolStripMenuItem, objectListToolStripMenuItem, statisticsToolStripMenuItem, dockableToolStripMenuItem, floatingToolStripMenuItem }); windowToolStripMenuItem.ForeColor = System.Drawing.Color.FromArgb(220, 220, 220); windowToolStripMenuItem.Name = "windowToolStripMenuItem"; windowToolStripMenuItem.Size = new System.Drawing.Size(63, 25); @@ -1925,6 +1926,15 @@ private void InitializeComponent() importedGeometryBrowserToolstripMenuItem.Tag = "ShowImportedGeometryBrowser"; importedGeometryBrowserToolstripMenuItem.Text = "ShowImportedGeometryBrowser"; // + // contentBrowserToolStripMenuItem + // + contentBrowserToolStripMenuItem.BackColor = System.Drawing.Color.FromArgb(60, 63, 65); + contentBrowserToolStripMenuItem.ForeColor = System.Drawing.Color.FromArgb(220, 220, 220); + contentBrowserToolStripMenuItem.Name = "contentBrowserToolStripMenuItem"; + contentBrowserToolStripMenuItem.Size = new System.Drawing.Size(246, 22); + contentBrowserToolStripMenuItem.Tag = "ShowContentBrowser"; + contentBrowserToolStripMenuItem.Text = "ShowContentBrowser"; + // // triggerListToolStripMenuItem // triggerListToolStripMenuItem.BackColor = System.Drawing.Color.FromArgb(60, 63, 65); @@ -2416,6 +2426,7 @@ private void InitializeComponent() private ToolStripSeparator toolStripSeparator8; private ToolStripMenuItem ShowRealTintForObjectsToolStripMenuItem; private ToolStripMenuItem importedGeometryBrowserToolstripMenuItem; + private ToolStripMenuItem contentBrowserToolStripMenuItem; private ToolStripMenuItem addSpriteToolStripMenuItem; private ToolStripMenuItem toolStripMenuItem9; private ToolStripMenuItem addMemoToolStripMenuItem; diff --git a/TombEditor/Forms/FormMain.cs b/TombEditor/Forms/FormMain.cs index e1b2a591e0..ce06753ef1 100644 --- a/TombEditor/Forms/FormMain.cs +++ b/TombEditor/Forms/FormMain.cs @@ -31,6 +31,7 @@ public partial class FormMain : DarkForm new RoomOptions(), new ItemBrowser(), new ImportedGeometryBrowser(), + new ContentBrowser(), new SectorOptions(), new Lighting(), new Palette(), @@ -41,6 +42,7 @@ public partial class FormMain : DarkForm // Floating tool boxes are placed on 3D view at runtime private readonly ToolPaletteFloating ToolBox = new ToolPaletteFloating(); + private readonly Controls.ObjectBrush.ObjectBrushToolbox ObjectBrushSettings = new Controls.ObjectBrush.ObjectBrushToolbox(); public FormMain(Editor editor) { @@ -70,7 +72,6 @@ public FormMain(Editor editor) // Restore window settings and prepare UI Configuration.LoadWindowProperties(this, _editor.Configuration); - LoadWindowLayout(_editor.Configuration); GenerateMenusRecursive(menuStrip.Items); UpdateUIColours(); UpdateControls(); @@ -186,6 +187,23 @@ obj is Editor.LevelChangedEvent || splitSectorObjectOnSelectionToolStripMenuItem.Enabled = _editor.SelectedObject is SectorBasedObjectInstance && validSectorSelection; } + // Show/hide object brush settings toolbox based on active mode. + if (obj is Editor.ToolChangedEvent || obj is Editor.ModeChangedEvent || obj is Editor.InitEvent) + { + bool showBrushToolbox = _editor.Mode == EditorMode.ObjectPlacement; + + if (showBrushToolbox && ObjectBrushSettings.Parent == null) + { + GetWindow().AddToolbox(ObjectBrushSettings); + ObjectBrushSettings.Location = _editor.Configuration.Rendering3D_ObjectBrushToolboxPosition; + } + else if (!showBrushToolbox && ObjectBrushSettings.Parent != null) + { + _editor.Configuration.Rendering3D_ObjectBrushToolboxPosition = ObjectBrushSettings.Location; + GetWindow().RemoveToolbox(ObjectBrushSettings); + } + } + // Update autosave status if (obj is Editor.AutosaveEvent) { @@ -490,6 +508,7 @@ private void LoadWindowLayout(Configuration configuration) floatingToolStripMenuItem.Checked = configuration.Rendering3D_ToolboxVisible; ToolBox.Location = configuration.Rendering3D_ToolboxPosition; + ObjectBrushSettings.Location = configuration.Rendering3D_ObjectBrushToolboxPosition; } private void SaveWindowLayout(Configuration configuration) @@ -498,6 +517,9 @@ private void SaveWindowLayout(Configuration configuration) configuration.Rendering3D_ToolboxVisible = floatingToolStripMenuItem.Checked; configuration.Rendering3D_ToolboxPosition = ToolBox.Location; + + if (ObjectBrushSettings.Parent != null) + configuration.Rendering3D_ObjectBrushToolboxPosition = ObjectBrushSettings.Location; } protected override bool ProcessDialogKey(Keys keyData) @@ -559,6 +581,7 @@ private void ToolWindow_BuildMenu() roomOptionsToolStripMenuItem.Checked = dockArea.ContainsContent(GetWindow()); itemBrowserToolStripMenuItem.Checked = dockArea.ContainsContent(GetWindow()); importedGeometryBrowserToolstripMenuItem.Checked = dockArea.ContainsContent(GetWindow()); + contentBrowserToolStripMenuItem.Checked = dockArea.ContainsContent(GetWindow()); triggerListToolStripMenuItem.Checked = dockArea.ContainsContent(GetWindow()); objectListToolStripMenuItem.Checked = dockArea.ContainsContent(GetWindow()); lightingToolStripMenuItem.Checked = dockArea.ContainsContent(GetWindow()); @@ -601,6 +624,12 @@ protected override void OnActivated(EventArgs e) _editor.Focus(); } + protected override void OnShown(EventArgs e) + { + base.OnShown(e); + LoadWindowLayout(_editor.Configuration); + } + private void aboutToolStripMenuItem_Click(object sender, EventArgs e) { using (FormAbout form = new FormAbout(Properties.Resources.misc_AboutScreen_800)) diff --git a/TombEditor/Forms/FormSearch.cs b/TombEditor/Forms/FormSearch.cs index c95f65da10..7e172a3cbf 100644 --- a/TombEditor/Forms/FormSearch.cs +++ b/TombEditor/Forms/FormSearch.cs @@ -382,8 +382,12 @@ private void SelectObject(ObjectType obj) _editor.SelectedRoom = (Room)obj; else if (obj is ObjectInstance) _editor.ShowObject((ObjectInstance)obj); - else if (obj is ItemType) - _editor.ChosenItem = (ItemType)obj; + else if (obj is ItemType itemType) + { + var wadObj = itemType.ToIWadObject(_editor.Level.Settings); + if (wadObj != null) + _editor.ChosenItems = new[] { wadObj }; + } } private void DeleteObjects() diff --git a/TombEditor/Forms/FormTextureRemap.cs b/TombEditor/Forms/FormTextureRemap.cs index b46774aac7..a59c460fcc 100644 --- a/TombEditor/Forms/FormTextureRemap.cs +++ b/TombEditor/Forms/FormTextureRemap.cs @@ -218,7 +218,7 @@ private void butOk_Click(object sender, EventArgs e) } // Send out updates - Parallel.ForEach(relevantRooms, room => room.BuildGeometry()); + Parallel.ForEach(relevantRooms, room => room.Rebuild(_editor.ShouldRelight, _editor.Configuration.Rendering3D_HighQualityLightPreview)); foreach (Room room in relevantRooms) _editor.RoomTextureChange(room); diff --git a/TombEditor/Hotkey.cs b/TombEditor/Hotkey.cs index ce05767314..3d99a2380e 100644 --- a/TombEditor/Hotkey.cs +++ b/TombEditor/Hotkey.cs @@ -251,6 +251,7 @@ private void GenerateDefault(KeyboardLayout layout) this["SwitchGeometryMode"] = new SortedSet { Keys.F2 }; this["SwitchFaceEditMode"] = new SortedSet { Keys.F3 }; this["SwitchLightingMode"] = new SortedSet { Keys.F4 }; + this["SwitchObjectPlacementMode"] = new SortedSet { Keys.F7 }; this["ResetCamera"] = new SortedSet { Keys.F6 }; this["AddTrigger"] = new SortedSet { Keys.T }; this["AddTriggerWithBookmark"] = new SortedSet { Keys.T | Keys.Shift }; diff --git a/TombEditor/Properties/DesignTimeResources.xaml b/TombEditor/Properties/DesignTimeResources.xaml new file mode 100644 index 0000000000..1e614024b1 --- /dev/null +++ b/TombEditor/Properties/DesignTimeResources.xaml @@ -0,0 +1,6 @@ + + + + + + diff --git a/TombEditor/Properties/Resources.Designer.cs b/TombEditor/Properties/Resources.Designer.cs index f53d05977a..7738dc5e17 100644 --- a/TombEditor/Properties/Resources.Designer.cs +++ b/TombEditor/Properties/Resources.Designer.cs @@ -1300,6 +1300,16 @@ internal static System.Drawing.Bitmap toolbox_Bulldozer_1_16 { } } + /// + /// Looks up a localized resource of type System.Drawing.Bitmap. + /// + internal static System.Drawing.Bitmap toolbox_Deselection_16 { + get { + object obj = ResourceManager.GetObject("toolbox_Deselection_16", resourceCulture); + return ((System.Drawing.Bitmap)(obj)); + } + } + /// /// Looks up a localized resource of type System.Drawing.Bitmap. /// @@ -1450,6 +1460,26 @@ internal static System.Drawing.Bitmap toolbox_Normal_16 { } } + /// + /// Looks up a localized resource of type System.Drawing.Bitmap. + /// + internal static System.Drawing.Bitmap toolbox_ObjectPlacement_16 { + get { + object obj = ResourceManager.GetObject("toolbox_ObjectPlacement_16", resourceCulture); + return ((System.Drawing.Bitmap)(obj)); + } + } + + /// + /// Looks up a localized resource of type System.Drawing.Bitmap. + /// + internal static System.Drawing.Bitmap toolbox_ObjectSelection_16 { + get { + object obj = ResourceManager.GetObject("toolbox_ObjectSelection_16", resourceCulture); + return ((System.Drawing.Bitmap)(obj)); + } + } + /// /// Looks up a localized resource of type System.Drawing.Bitmap. /// @@ -1490,6 +1520,16 @@ internal static System.Drawing.Bitmap toolbox_PortalDigger_16 { } } + /// + /// Looks up a localized resource of type System.Drawing.Bitmap. + /// + internal static System.Drawing.Bitmap toolbox_Ruler_16 { + get { + object obj = ResourceManager.GetObject("toolbox_Ruler_16", resourceCulture); + return ((System.Drawing.Bitmap)(obj)); + } + } + /// /// Looks up a localized resource of type System.Drawing.Bitmap. /// @@ -1539,5 +1579,25 @@ internal static System.Drawing.Bitmap toolbox_Vertex_16 { return ((System.Drawing.Bitmap)(obj)); } } + + /// + /// Looks up a localized resource of type System.Drawing.Bitmap. + /// + internal static System.Drawing.Bitmap actions_StarOutlined_16 { + get { + object obj = ResourceManager.GetObject("actions_StarOutlined_16", resourceCulture); + return ((System.Drawing.Bitmap)(obj)); + } + } + + /// + /// Looks up a localized resource of type System.Drawing.Bitmap. + /// + internal static System.Drawing.Bitmap actions_StarFilled_16 { + get { + object obj = ResourceManager.GetObject("actions_StarFilled_16", resourceCulture); + return ((System.Drawing.Bitmap)(obj)); + } + } } } diff --git a/TombEditor/Properties/Resources.resx b/TombEditor/Properties/Resources.resx index f4fa1b3d46..e1be42b6cd 100644 --- a/TombEditor/Properties/Resources.resx +++ b/TombEditor/Properties/Resources.resx @@ -124,6 +124,18 @@ ..\Resources\icons_toolbox\toolbox_GroupRamp-16.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a + + ..\Resources\icons_toolbox\toolbox_ObjectSelection-16.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a + + + ..\Resources\icons_toolbox\toolbox_Deselection-16.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a + + + ..\Resources\icons_toolbox\toolbox_Ruler-16.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a + + + ..\Resources\icons_toolbox\toolbox_ObjectPlacement-16.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a + ..\Resources\icons_general\general_redo-16.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a @@ -562,4 +574,10 @@ ..\Resources\icons_actions\actions_play_fast-16.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a + + ..\Resources\icons_actions\actions_StarOutlined-16.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a + + + ..\Resources\icons_actions\actions_StarFilled-16.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a + \ No newline at end of file diff --git a/TombEditor/Resources/assets/Wads/TombEngine.wad2 b/TombEditor/Resources/assets/Wads/TombEngine.wad2 index e01c0bc64d..b97064b520 100644 Binary files a/TombEditor/Resources/assets/Wads/TombEngine.wad2 and b/TombEditor/Resources/assets/Wads/TombEngine.wad2 differ diff --git a/TombEditor/Resources/icons_actions/actions_StarFilled-16.png b/TombEditor/Resources/icons_actions/actions_StarFilled-16.png new file mode 100644 index 0000000000..be826f59c6 Binary files /dev/null and b/TombEditor/Resources/icons_actions/actions_StarFilled-16.png differ diff --git a/TombEditor/Resources/icons_actions/actions_StarOutlined-16.png b/TombEditor/Resources/icons_actions/actions_StarOutlined-16.png new file mode 100644 index 0000000000..759bbe65dd Binary files /dev/null and b/TombEditor/Resources/icons_actions/actions_StarOutlined-16.png differ diff --git a/TombEditor/Resources/icons_toolbox/toolbox_Deselection-16.png b/TombEditor/Resources/icons_toolbox/toolbox_Deselection-16.png new file mode 100644 index 0000000000..9c1bba2ae3 Binary files /dev/null and b/TombEditor/Resources/icons_toolbox/toolbox_Deselection-16.png differ diff --git a/TombEditor/Resources/icons_toolbox/toolbox_ObjectPlacement-16.png b/TombEditor/Resources/icons_toolbox/toolbox_ObjectPlacement-16.png new file mode 100644 index 0000000000..e1a6dbbdc5 Binary files /dev/null and b/TombEditor/Resources/icons_toolbox/toolbox_ObjectPlacement-16.png differ diff --git a/TombEditor/Resources/icons_toolbox/toolbox_ObjectSelection-16.png b/TombEditor/Resources/icons_toolbox/toolbox_ObjectSelection-16.png new file mode 100644 index 0000000000..46cb1e7747 Binary files /dev/null and b/TombEditor/Resources/icons_toolbox/toolbox_ObjectSelection-16.png differ diff --git a/TombEditor/Resources/icons_toolbox/toolbox_Ruler-16.png b/TombEditor/Resources/icons_toolbox/toolbox_Ruler-16.png new file mode 100644 index 0000000000..e1bc6c2a64 Binary files /dev/null and b/TombEditor/Resources/icons_toolbox/toolbox_Ruler-16.png differ diff --git a/TombEditor/TombEditor.csproj b/TombEditor/TombEditor.csproj index 17247584f2..d78607f265 100644 --- a/TombEditor/TombEditor.csproj +++ b/TombEditor/TombEditor.csproj @@ -29,6 +29,13 @@ TombEditor.Program + + + MSBuild:Compile + Designer + true + + ..\Libs\CustomTabControl.dll @@ -118,6 +125,7 @@ + @@ -131,14 +139,25 @@ + + + + + + + + + + + diff --git a/TombEditor/ToolWindows/ContentBrowser.Designer.cs b/TombEditor/ToolWindows/ContentBrowser.Designer.cs new file mode 100644 index 0000000000..59b6a0911d --- /dev/null +++ b/TombEditor/ToolWindows/ContentBrowser.Designer.cs @@ -0,0 +1,41 @@ +namespace TombEditor.ToolWindows +{ + partial class ContentBrowser + { + private System.ComponentModel.IContainer components = null; + + private void InitializeComponent() + { + this.components = new System.ComponentModel.Container(); + this.toolTip = new System.Windows.Forms.ToolTip(this.components); + this.elementHost = new System.Windows.Forms.Integration.ElementHost(); + this.contentBrowserView = new Views.ContentBrowserView(); + this.SuspendLayout(); + // + // elementHost + // + this.elementHost.Dock = System.Windows.Forms.DockStyle.Fill; + this.elementHost.Location = new System.Drawing.Point(0, 25); + this.elementHost.Name = "elementHost"; + this.elementHost.Size = new System.Drawing.Size(350, 350); + this.elementHost.TabIndex = 0; + this.elementHost.Child = this.contentBrowserView; + // + // ContentBrowser + // + this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F); + this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; + this.Controls.Add(this.elementHost); + this.DockText = "Content Browser"; + this.MinimumSize = new System.Drawing.Size(250, 200); + this.Name = "ContentBrowser"; + this.SerializationKey = "ContentBrowser"; + this.Size = new System.Drawing.Size(350, 400); + this.ResumeLayout(false); + } + + private System.Windows.Forms.ToolTip toolTip; + private System.Windows.Forms.Integration.ElementHost elementHost; + private Views.ContentBrowserView contentBrowserView; + } +} diff --git a/TombEditor/ToolWindows/ContentBrowser.cs b/TombEditor/ToolWindows/ContentBrowser.cs new file mode 100644 index 0000000000..b078b1f346 --- /dev/null +++ b/TombEditor/ToolWindows/ContentBrowser.cs @@ -0,0 +1,420 @@ +using DarkUI.Docking; +using NLog; +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Windows.Forms; +using TombEditor.ViewModels; +using TombLib.Controls; +using TombLib.GeometryIO; +using TombLib.LevelData; +using TombLib.Utils; +using TombLib.Wad; + +namespace TombEditor.ToolWindows; + +public partial class ContentBrowser : DarkToolWindow +{ + private static readonly Logger logger = LogManager.GetCurrentClassLogger(); + + private readonly Editor _editor; + private readonly ContentBrowserViewModel _viewModel; + private OffscreenItemRenderer _renderer; + + private Timer _thumbnailTimer; + private List _thumbnailQueue; + private int _thumbnailQueueIndex; + + private bool _suppressEditorSync; + + private bool _refreshPending; + + private const int ThumbnailBatchSize = 10; + + public ContentBrowser() + { + InitializeComponent(); + + _editor = Editor.Instance; + _viewModel = new ContentBrowserViewModel(); + + // Set the WPF view's DataContext. + contentBrowserView.DataContext = _viewModel; + + _viewModel.SelectedItemsChanged += ViewModel_SelectedItemsChanged; + _viewModel.DragDropRequested += ViewModel_DragDropRequested; + _viewModel.ThumbnailRenderRequested += ViewModel_ThumbnailRenderRequested; + _viewModel.LocateItemRequested += ViewModel_LocateItemRequested; + _viewModel.AddItemRequested += ViewModel_AddItemRequested; + _viewModel.AddWadRequested += ViewModel_AddWadRequested; + _viewModel.PropertyChanged += ViewModel_PropertyChanged; + _viewModel.FavoriteToggled += ViewModel_FavoriteToggled; + + // Load saved tile width from configuration. + _viewModel.TileWidth = (double)_editor.Configuration.ContentBrowser_TileWidth; + + // Timer for batched/deferred thumbnail rendering (50ms between batches). + _thumbnailTimer = new Timer { Interval = 50 }; + _thumbnailTimer.Tick += ThumbnailTimer_Tick; + + // Accept file drops from Windows Explorer (WAD files and 3D geometry files). + AllowDrop = true; + _viewModel.FilesDropped += ViewModel_FilesDropped; + + // Subscribe to viewport scroll for lazy thumbnail rendering. + contentBrowserView.ViewportScrolled += ContentBrowserView_ViewportScrolled; + + // Subscribe to editor events. + _editor.EditorEventRaised += EditorEventRaised; + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _editor.EditorEventRaised -= EditorEventRaised; + + _viewModel.SelectedItemsChanged -= ViewModel_SelectedItemsChanged; + _viewModel.DragDropRequested -= ViewModel_DragDropRequested; + _viewModel.ThumbnailRenderRequested -= ViewModel_ThumbnailRenderRequested; + _viewModel.LocateItemRequested -= ViewModel_LocateItemRequested; + _viewModel.AddItemRequested -= ViewModel_AddItemRequested; + _viewModel.AddWadRequested -= ViewModel_AddWadRequested; + _viewModel.PropertyChanged -= ViewModel_PropertyChanged; + _viewModel.FavoriteToggled -= ViewModel_FavoriteToggled; + _viewModel.FilesDropped -= ViewModel_FilesDropped; + + contentBrowserView.ViewportScrolled -= ContentBrowserView_ViewportScrolled; + + _thumbnailTimer?.Stop(); + _thumbnailTimer?.Dispose(); + _thumbnailTimer = null; + + _renderer?.Dispose(); + _renderer = null; + } + + if (disposing && components is not null) + components.Dispose(); + + base.Dispose(disposing); + } + + // Delegates drag-drop to WinForms host. Single item passes IWadObject directly. + // multiple items are wrapped in an IWadObject[] array. + private void ViewModel_DragDropRequested(object sender, IReadOnlyList items) + { + if (items is null || items.Count == 0) + return; + + if (items.Count == 1) + { + // Single item: pass IWadObject directly for backward compatibility. + if (items[0].WadObject is not null) + DoDragDrop(items[0].WadObject, DragDropEffects.Copy); + } + else + { + // Multiple items: pass as IWadObject array. + var wadObjects = items.Where(i => i.WadObject is not null).Select(i => i.WadObject).ToArray(); + + if (wadObjects.Length > 0) + DoDragDrop(wadObjects, DragDropEffects.Copy); + } + } + + private void ViewModel_LocateItemRequested(object sender, AssetItemViewModel item) + { + if (item is null) + return; + + if (item.WadObject is ImportedGeometry geo) + { + EditorActions.FindImportedGeometry(geo); + } + else if (item.WadObject is WadMoveable or WadStatic) + { + _editor.ChosenItems = new[] { item.WadObject }; + EditorActions.FindItem(); + } + + // Scroll to make the item visible in the list. + contentBrowserView.ScrollToItem(item); + } + + private void ViewModel_AddItemRequested(object sender, EventArgs e) + { + var selected = _viewModel.SelectedItem; + + if (selected is null) + return; + + if (selected.WadObject is ImportedGeometry) + _editor.Action = new EditorActionPlace(false, (l, r) => new ImportedGeometryInstance()); + else + CommandHandler.GetCommand("AddItem").Execute?.Invoke(new CommandArgs { Editor = _editor, Window = FindForm() }); + + // If the action was not set (e.g. validation failed), restore the tile animation immediately. + if (_editor.Action is not EditorActionPlace) + contentBrowserView.RestoreLastAnimation(); + } + + private void ViewModel_AddWadRequested(object sender, EventArgs e) + { + EditorActions.AddWad(this, null); + } + + private void ViewModel_FavoriteToggled(object sender, AssetItemViewModel item) + { + var settings = _editor.Level?.Settings; + + if (settings is null) + return; + + if (item.IsFavorite) + settings.Favorites.Add(item.FavoriteKey); + else + settings.Favorites.Remove(item.FavoriteKey); + } + + // Handles files dropped from Windows Explorer: WAD files are loaded as object archives. + // 3D geometry files are added as imported geometry. + private void ViewModel_FilesDropped(object sender, string[] files) + { + if (files is null || files.Length == 0) + return; + + var wadFiles = files + .Where(f => Wad2.FileExtensions.Matches(f)) + .Select(f => _editor.Level.Settings.MakeRelative(f, VariableType.LevelDirectory)) + .ToList(); + + if (wadFiles.Count > 0) + EditorActions.AddWad(this, wadFiles); + + foreach (var file in files.Where(f => BaseGeometryImporter.FileExtensions.Matches(f))) + EditorActions.AddImportedGeometry(this, _editor.Level.Settings.MakeRelative(file, VariableType.LevelDirectory)); + } + + private void ViewModel_PropertyChanged(object sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(ContentBrowserViewModel.TileWidth)) + _editor.Configuration.ContentBrowser_TileWidth = (float)_viewModel.TileWidth; + } + + private void ViewModel_ThumbnailRenderRequested(object sender, EventArgs e) + { + // Defer rendering until scroll/layout provides visible items. + QueueVisibleThumbnails(); + } + + private void ContentBrowserView_ViewportScrolled(object sender, EventArgs e) + { + QueueVisibleThumbnails(); + } + + // Queues only currently-visible items that still need thumbnails. + private void QueueVisibleThumbnails() + { + var visibleItems = contentBrowserView.GetVisibleItems() + .Where(i => i.Thumbnail is null && !_viewModel.HasCachedThumbnail(i)) + .ToList(); + + if (visibleItems.Count == 0) + return; + + _thumbnailQueue = visibleItems; + _thumbnailQueueIndex = 0; + _thumbnailTimer?.Stop(); + _thumbnailTimer?.Start(); + } + + private void ThumbnailTimer_Tick(object sender, EventArgs e) + { + if (_thumbnailQueue is null || _thumbnailQueueIndex >= _thumbnailQueue.Count) + { + // All items rendered; stop timer and clean up. + _thumbnailTimer?.Stop(); + _thumbnailQueue = null; + _thumbnailQueueIndex = 0; + _renderer?.GarbageCollect(); + return; + } + + RenderThumbnailBatch(); + } + + // Renders a small batch of thumbnails (up to ThumbnailBatchSize). + // Called from the timer tick so D3D11 rendering stays on the UI thread. + private void RenderThumbnailBatch() + { + try + { + _renderer ??= new OffscreenItemRenderer(); + + int end = Math.Min(_thumbnailQueueIndex + ThumbnailBatchSize, _thumbnailQueue.Count); + + for (int i = _thumbnailQueueIndex; i < end; i++) + { + var item = _thumbnailQueue[i]; + + try + { + // Skip items that already got a thumbnail (e.g. from cache restore). + if (item.Thumbnail is not null) + continue; + + // Apply Lara skin substitution for moveables (shared with ItemBrowser). + var renderObject = WadObjectRenderHelper.GetRenderObject(item.WadObject, _editor.Level.Settings); + + var image = _renderer.RenderThumbnail(renderObject, _editor.Level.Settings.GameVersion, _editor.Configuration.UI_ColorScheme.Color3DBackground); + var bitmapSource = AssetItemViewModel.ImageCToBitmapSource(image); + _viewModel.SetThumbnail(item, bitmapSource); + } + catch (Exception ex) + { + logger.Warn(ex, "Failed to render thumbnail for {0}.", item.Name); + } + } + + _thumbnailQueueIndex = end; + } + catch (Exception ex) + { + // If renderer creation fails, stop rendering. + logger.Error(ex, "Thumbnail renderer failed."); + + _thumbnailTimer?.Stop(); + _thumbnailQueue = null; + _thumbnailQueueIndex = 0; + _renderer?.Dispose(); + _renderer = null; + } + } + + private void EditorEventRaised(IEditorEvent obj) + { + _refreshPending = false; + + // Refresh asset list when wads, geometries, or game version change. + if (obj is Editor.LoadedWadsChangedEvent or + Editor.LoadedImportedGeometriesChangedEvent or + Editor.GameVersionChangedEvent or + Editor.LevelChangedEvent) + { + // Invalidate thumbnail cache when wads change since meshes may differ. + if (obj is Editor.LoadedWadsChangedEvent or + Editor.LoadedImportedGeometriesChangedEvent) + { + // Stop any in-progress rendering. + _thumbnailTimer?.Stop(); + _thumbnailQueue = null; + _thumbnailQueueIndex = 0; + + _renderer?.Dispose(); + _renderer = null; + } + + _refreshPending = true; + } + + // Also refresh on configuration change (game version display may change). + if (obj is Editor.ConfigurationChangedEvent) + _refreshPending = true; + + // Sync selection when items are chosen from other tool windows. + if (obj is Editor.ChosenItemsChangedEvent itemsChanged && !_suppressEditorSync) + { + var first = itemsChanged.Current?.FirstOrDefault(); + + if (first is not null) + SelectWadObject(first); + } + + // Update keyboard shortcuts. + if (obj is Editor.ConfigurationChangedEvent configChanged) + { + if (configChanged.UpdateKeyboardShortcuts) + CommandHandler.AssignCommandsToControls(_editor, this, toolTip, true); + } + + // Restore tile animation when the place action ends (object placed or action canceled). + if (obj is Editor.ActionChangedEvent actionEvent) + { + if (actionEvent.Previous is EditorActionPlace && actionEvent.Current is not EditorActionPlace) + contentBrowserView.RestoreLastAnimation(); + } + + // Activate default control. + if (obj is Editor.DefaultControlActivationEvent activationEvent) + { + if (DockPanel is not null && activationEvent.ContainerName == GetType().Name) + MakeActive(); + } + + // Initial load. + if (obj is Editor.InitEvent) + _refreshPending = true; + + // Coalesce multiple refresh triggers into a single call. + if (_refreshPending) + RefreshAssets(); + } + + private void RefreshAssets() + { + LevelSettings settings = _editor?.Level?.Settings; + + if (settings is null) + return; + + _viewModel.RefreshAssets(settings, _editor.Configuration.RenderingItem_HideInternalObjects); + } + + private void ViewModel_SelectedItemsChanged(object sender, IReadOnlyList items) + { + // When the user clears the ContentBrowser selection, leave ChosenItems unchanged + // so the legacy item browser and imported geometry browser still show the last chosen item. + + if (items.Count == 0) + { + _suppressEditorSync = true; + var potentialSelection = _editor.GetFirstWadObject(); + if (potentialSelection != null) + _editor.ChosenItems = new[] { potentialSelection }; + else + _editor.ChosenItems = Array.Empty(); + _suppressEditorSync = false; + return; + } + + var wadObjects = items + .Where(vm => vm.WadObject is not null) + .Select(vm => vm.WadObject) + .ToArray(); + + if (wadObjects.Length == 0) + return; + + _suppressEditorSync = true; + _editor.ChosenItems = wadObjects; + _suppressEditorSync = false; + + contentBrowserView.ScrollToItem(items[0]); + } + + private void SelectWadObject(IWadObject wadObject) + { + // Temporarily detach event to avoid feedback loop. + _viewModel.SelectedItemsChanged -= ViewModel_SelectedItemsChanged; + + var item = _viewModel.AllItems.FirstOrDefault(i => ReferenceEquals(i.WadObject, wadObject)); + + _viewModel.SelectedItem = item; + contentBrowserView.SetSelectionSilently(item); + contentBrowserView.ScrollToItem(item); + + _viewModel.SelectedItemsChanged += ViewModel_SelectedItemsChanged; + } +} diff --git a/TombEditor/ToolWindows/ImportedGeometryBrowser.cs b/TombEditor/ToolWindows/ImportedGeometryBrowser.cs index f89ff9c8de..313be6aca3 100644 --- a/TombEditor/ToolWindows/ImportedGeometryBrowser.cs +++ b/TombEditor/ToolWindows/ImportedGeometryBrowser.cs @@ -11,6 +11,7 @@ namespace TombEditor.ToolWindows public partial class ImportedGeometryBrowser : DarkToolWindow { private readonly Editor _editor; + private bool _suppressEditorSync = false; public ImportedGeometryBrowser() { @@ -58,14 +59,16 @@ private void EditorEventRaised(IEditorEvent obj) } } - if (obj is Editor.ChosenImportedGeometryChangedEvent) + if (obj is Editor.ChosenItemsChangedEvent itemsChanged) { - var e = (Editor.ChosenImportedGeometryChangedEvent)obj; - if (e.Current != null) + var geo = itemsChanged.Current?.OfType().FirstOrDefault(); + if (geo != null) { - comboItems.SelectedItem = panelItem.CurrentObject = e.Current; + _suppressEditorSync = true; + comboItems.SelectedItem = panelItem.CurrentObject = geo; MakeActive(); panelItem.ResetCamera(); + _suppressEditorSync = false; } } @@ -94,8 +97,11 @@ private void EditorEventRaised(IEditorEvent obj) private void comboItems_SelectedIndexChanged(object sender, EventArgs e) { - if (comboItems.SelectedItem is ImportedGeometry) - _editor.ChosenImportedGeometry = (ImportedGeometry)comboItems.SelectedItem; + if (_suppressEditorSync) + return; + + if (comboItems.SelectedItem is ImportedGeometry geo) + _editor.ChosenItems = new IWadObject[] { geo }; } private void comboItems_Format(object sender, ListControlConvertEventArgs e) diff --git a/TombEditor/ToolWindows/ItemBrowser.cs b/TombEditor/ToolWindows/ItemBrowser.cs index bd106d6e08..5ce4674e51 100644 --- a/TombEditor/ToolWindows/ItemBrowser.cs +++ b/TombEditor/ToolWindows/ItemBrowser.cs @@ -1,8 +1,8 @@ using DarkUI.Docking; using System; using System.IO; -using System.Linq; using System.Windows.Forms; +using TombLib.Controls; using TombLib.LevelData; using TombLib.Rendering; using TombLib.Wad; @@ -13,6 +13,7 @@ namespace TombEditor.ToolWindows public partial class ItemBrowser : DarkToolWindow { private readonly Editor _editor; + private bool _suppressEditorSync = false; public ItemBrowser() { @@ -61,14 +62,15 @@ obj is Editor.GameVersionChangedEvent || if (comboItems.Items.Count > 0) { - // Check if any reloaded wads still have current selected item present. If they do, re-select it - // to preserve item list position. If item is not present, just reset selection to first item in the list. + // Check if any reloaded wads still have the currently chosen item. If so, re-select it + // to preserve list position. Otherwise, reset selection to the first item in the list. - if (_editor.ChosenItem.HasValue && - _editor.Level.Settings.Wads.Any(w => w.Wad != null && ((!_editor.ChosenItem.Value.IsStatic && w.Wad.Moveables.Any(w2 => w2.Key == _editor.ChosenItem.Value.MoveableId)) || - ( _editor.ChosenItem.Value.IsStatic && w.Wad.Statics.Any (w2 => w2.Key == _editor.ChosenItem.Value.StaticId))))) + var chosenWadObject = _editor.GetFirstWadObject(); + + if (chosenWadObject != null && comboItems.Items.Contains(chosenWadObject)) { - ChoseItem(_editor.ChosenItem.Value); + comboItems.SelectedItem = panelItem.CurrentObject = chosenWadObject; + panelItem.ResetCamera(); } else { @@ -91,17 +93,21 @@ obj is Editor.GameVersionChangedEvent || } } - // Update selection of items combo box - if (obj is Editor.ChosenItemChangedEvent) + // Update selection of items combo box. + if (obj is Editor.ChosenItemsChangedEvent) { - var e = (Editor.ChosenItemChangedEvent)obj; - if (!e.Current.HasValue) - comboItems.SelectedItem = panelItem.CurrentObject = null; - else - ChoseItem(e.Current.Value); + _suppressEditorSync = true; + var wadObject = _editor.GetFirstWadObject(); + if (wadObject != null) + { + comboItems.SelectedItem = panelItem.CurrentObject = wadObject; + MakeActive(); + panelItem.ResetCamera(); + } + _suppressEditorSync = false; } - if (obj is Editor.ChosenItemChangedEvent || + if (obj is Editor.ChosenItemsChangedEvent || obj is Editor.GameVersionChangedEvent || obj is Editor.LevelChangedEvent || obj is Editor.LoadedWadsChangedEvent || @@ -132,61 +138,33 @@ obj is Editor.LoadedWadsChangedEvent || panelItem.AnimatePreview = _editor.Configuration.RenderingItem_Animate; lblFromWad.Visible = _editor.Configuration.RenderingItem_ShowMultipleWadsPrompt; } - - } - - private void ChoseItem(ItemType item) - { - if (item == null) - return; - - if (item.IsStatic) - { - comboItems.SelectedItem = panelItem.CurrentObject = _editor.Level.Settings.WadTryGetStatic(item.StaticId); - } - else - { - if (!_editor.Configuration.RenderingItem_HideInternalObjects || - !TrCatalog.IsHidden(_editor.Level.Settings.GameVersion, item.MoveableId.TypeId)) - { - comboItems.SelectedItem = panelItem.CurrentObject = _editor.Level.Settings.WadTryGetMoveable(item.MoveableId); - } - } - - MakeActive(); - panelItem.ResetCamera(); } private void FindLaraSkin() { - if (comboItems.Items.Count == 0 || comboItems.SelectedIndex < 0 || !(comboItems.SelectedItem is WadMoveable)) + if (comboItems.Items.Count == 0 || comboItems.SelectedIndex < 0 || !(comboItems.SelectedItem is WadMoveable item)) return; - var item = comboItems.SelectedItem as WadMoveable; - var skinId = new WadMoveableId(TrCatalog.GetMoveableSkin(_editor.Level.Settings.GameVersion, item.Id.TypeId)); - var skin = _editor.Level.Settings.WadTryGetMoveable(skinId); - - if (skin != null && skin != item) - panelItem.CurrentObject = item.ReplaceDummyMeshes(skin); - else - panelItem.CurrentObject = item; - + panelItem.CurrentObject = WadObjectRenderHelper.GetRenderObject(item, _editor.Level.Settings); panelItem.ResetCamera(); } private void comboItems_SelectedIndexChanged(object sender, EventArgs e) { - if (comboItems.SelectedItem == null) - _editor.ChosenItem = null; - else if (comboItems.SelectedItem is WadMoveable) - _editor.ChosenItem = new ItemType(((WadMoveable)comboItems.SelectedItem).Id, _editor?.Level?.Settings); - else if (comboItems.SelectedItem is WadStatic) - _editor.ChosenItem = new ItemType(((WadStatic)comboItems.SelectedItem).Id, _editor?.Level?.Settings); - - if (_editor.ChosenItem != null) + if (!_suppressEditorSync && comboItems.SelectedItem is IWadObject wadObject) + _editor.ChosenItems = new[] { wadObject }; + + var itemType = comboItems.SelectedItem switch + { + WadMoveable m => (ItemType?)new ItemType(m.Id, _editor?.Level?.Settings), + WadStatic s => (ItemType?)new ItemType(s.Id, _editor?.Level?.Settings), + _ => null + }; + + if (itemType != null) { bool multiple; - var wad = _editor.Level.Settings.WadTryGetWad(_editor.ChosenItem.Value, out multiple); + var wad = _editor.Level.Settings.WadTryGetWad(itemType.Value, out multiple); if (wad != null) { diff --git a/TombEditor/ToolWindows/MainView.Designer.cs b/TombEditor/ToolWindows/MainView.Designer.cs index 05032bdeeb..e65f6fc510 100644 --- a/TombEditor/ToolWindows/MainView.Designer.cs +++ b/TombEditor/ToolWindows/MainView.Designer.cs @@ -23,6 +23,7 @@ private void InitializeComponent() panel3D = new TombEditor.Controls.Panel3D.Panel3D(); but2D = new System.Windows.Forms.ToolStripButton(); but3D = new System.Windows.Forms.ToolStripButton(); + butObjectPlacement = new System.Windows.Forms.ToolStripButton(); butFaceEdit = new System.Windows.Forms.ToolStripButton(); butLightingMode = new System.Windows.Forms.ToolStripButton(); butUndo = new System.Windows.Forms.ToolStripButton(); @@ -97,7 +98,7 @@ private void InitializeComponent() toolStrip.BackColor = System.Drawing.Color.FromArgb(60, 63, 65); toolStrip.ForeColor = System.Drawing.Color.FromArgb(220, 220, 220); toolStrip.GripStyle = System.Windows.Forms.ToolStripGripStyle.Hidden; - toolStrip.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { but2D, but3D, butFaceEdit, butLightingMode, butUndo, butRedo, butCenterCamera, butDrawPortals, butDrawAllRooms, butDrawHorizon, butDrawRoomNames, butDrawCardinalDirections, butDrawExtraBlendingModes, butHideTransparentFaces, butBilinearFilter, butDrawWhiteLighting, butDrawStaticTint, butDrawIllegalSlopes, butDrawSlideDirections, butDisableGeometryPicking, butDisableHiddenRoomPicking, butDrawObjects, butFlipMap, butCopy, butPaste, butStamp, butOpacityNone, butOpacitySolidFaces, butOpacityTraversableFaces, butMirror, butAddCamera, butAddSprite, butAddFlybyCamera, butAddSink, butAddSoundSource, butAddImportedGeometry, butAddGhostBlock, butAddMemo, butCompileLevel, butCompileLevelAndPlay, butCompileAndPlayPreview, butAddBoxVolume, butAddSphereVolume, butTextureFloor, butTextureCeiling, butTextureWalls, butEditLevelSettings, butToggleFlyMode, butSearch, butSearchAndReplaceObjects }); + toolStrip.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { but2D, but3D, butFaceEdit, butObjectPlacement, butLightingMode, butUndo, butRedo, butCenterCamera, butDrawPortals, butDrawAllRooms, butDrawHorizon, butDrawRoomNames, butDrawCardinalDirections, butDrawExtraBlendingModes, butHideTransparentFaces, butBilinearFilter, butDrawWhiteLighting, butDrawStaticTint, butDrawIllegalSlopes, butDrawSlideDirections, butDisableGeometryPicking, butDisableHiddenRoomPicking, butDrawObjects, butFlipMap, butCopy, butPaste, butStamp, butOpacityNone, butOpacitySolidFaces, butOpacityTraversableFaces, butMirror, butAddCamera, butAddSprite, butAddFlybyCamera, butAddSink, butAddSoundSource, butAddImportedGeometry, butAddGhostBlock, butAddMemo, butCompileLevel, butCompileLevelAndPlay, butCompileAndPlayPreview, butAddBoxVolume, butAddSphereVolume, butTextureFloor, butTextureCeiling, butTextureWalls, butEditLevelSettings, butToggleFlyMode, butSearch, butSearchAndReplaceObjects }); toolStrip.Location = new System.Drawing.Point(0, 0); toolStrip.Name = "toolStrip"; toolStrip.Padding = new System.Windows.Forms.Padding(6, 0, 1, 0); @@ -130,6 +131,17 @@ private void InitializeComponent() but3D.Size = new System.Drawing.Size(23, 29); but3D.Tag = "SwitchGeometryMode"; // + // butObjectPlacement + // + butObjectPlacement.BackColor = System.Drawing.Color.FromArgb(60, 63, 65); + butObjectPlacement.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Image; + butObjectPlacement.ForeColor = System.Drawing.Color.FromArgb(220, 220, 220); + butObjectPlacement.Image = Properties.Resources.toolbox_ObjectPlacement_16; + butObjectPlacement.ImageTransparentColor = System.Drawing.Color.Magenta; + butObjectPlacement.Name = "butObjectPlacement"; + butObjectPlacement.Size = new System.Drawing.Size(23, 29); + butObjectPlacement.Tag = "SwitchObjectPlacementMode"; + // // butFaceEdit // butFaceEdit.BackColor = System.Drawing.Color.FromArgb(60, 63, 65); @@ -891,6 +903,7 @@ private void InitializeComponent() private DarkUI.Controls.DarkToolStrip toolStrip; private System.Windows.Forms.ToolStripButton but2D; private System.Windows.Forms.ToolStripButton but3D; + private System.Windows.Forms.ToolStripButton butObjectPlacement; private System.Windows.Forms.ToolStripButton butFaceEdit; private System.Windows.Forms.ToolStripButton butLightingMode; private System.Windows.Forms.ToolStripButton butFlipMap; diff --git a/TombEditor/ToolWindows/MainView.cs b/TombEditor/ToolWindows/MainView.cs index 711e19edcf..f557027bd1 100644 --- a/TombEditor/ToolWindows/MainView.cs +++ b/TombEditor/ToolWindows/MainView.cs @@ -147,9 +147,10 @@ private void EditorEventRaised(IEditorEvent obj) but3D.Checked = mode == EditorMode.Geometry; butLightingMode.Checked = mode == EditorMode.Lighting; butFaceEdit.Checked = mode == EditorMode.FaceEdit; + butObjectPlacement.Checked = mode == EditorMode.ObjectPlacement; panel2DMap.Visible = mode == EditorMode.Map2D; - panel3D.Visible = mode == EditorMode.FaceEdit || mode == EditorMode.Geometry || mode == EditorMode.Lighting; + panel3D.Visible = mode != EditorMode.Map2D; } // Update flipmap toolbar button diff --git a/TombEditor/ToolWindows/ObjectList.cs b/TombEditor/ToolWindows/ObjectList.cs index f99e50cc96..f722db33d2 100644 --- a/TombEditor/ToolWindows/ObjectList.cs +++ b/TombEditor/ToolWindows/ObjectList.cs @@ -1,6 +1,7 @@ using DarkUI.Controls; using DarkUI.Docking; using System; +using System.Collections.ObjectModel; using System.Linq; using System.Windows.Forms; using TombLib.LevelData; @@ -31,38 +32,59 @@ protected override void Dispose(bool disposing) private void EditorEventRaised(IEditorEvent obj) { - // Update the trigger control - if (obj is Editor.SelectedRoomChangedEvent || - obj is Editor.ObjectChangedEvent || - obj is Editor.GameVersionChangedEvent) + // Full rebuild when the displayed room or game version changes. + if (obj is Editor.SelectedRoomChangedEvent || obj is Editor.GameVersionChangedEvent) { - // Disable events + RebuildObjectList(); + + // Also sync selection state after a room switch. _lockList = true; + lstObjects.ClearSelection(); + + if (_editor.SelectedObject?.Room == _editor.SelectedRoom) + SelectObjectInList(_editor.SelectedObject); - // Preserve selection - var currentObject = lstObjects.SelectedItems.Count > 0 ? lstObjects.SelectedItem.Tag : null; - lstObjects.Items.Clear(); + _lockList = false; + return; + } - foreach (var o in _editor.SelectedRoom.Objects) - lstObjects.Items.Add(new DarkListItem(o.ToShortString()) { Tag = o }); + if (obj is Editor.ObjectChangedEvent) + { + var e = (Editor.ObjectChangedEvent)obj; - foreach (var o in _editor.SelectedRoom.GhostBlocks) - lstObjects.Items.Add(new DarkListItem(o.ToShortString()) { Tag = o }); + // Events from other rooms are not visible in this panel. + if (e.Room != _editor.SelectedRoom) + return; - // Restore selection - for (int i = 0; i < lstObjects.Items.Count; i++) - if (lstObjects.Items[i].Tag == currentObject) - { - lstObjects.SelectItem(i); + _lockList = true; + + switch (e.ChangeType) + { + case ObjectChangeType.Add: + lstObjects.Items.Add(new DarkListItem(e.Object.ToShortString()) { Tag = e.Object }); break; - } - // Reenable events + case ObjectChangeType.Remove: + var toRemove = lstObjects.Items.FirstOrDefault(i => i.Tag == e.Object); + if (toRemove != null) + lstObjects.Items.Remove(toRemove); + break; + + case ObjectChangeType.Change: + var toUpdate = lstObjects.Items.FirstOrDefault(i => i.Tag == e.Object); + if (toUpdate != null) + toUpdate.Text = e.Object.ToShortString(); // TextChanged fires an incremental item update in DarkListView + else + RebuildObjectList(); // Item missing — fall back to full rebuild. + break; + } + _lockList = false; + return; } // Update the object control selection - if (obj is Editor.SelectedRoomChangedEvent || obj is Editor.SelectedObjectChangedEvent) + if (obj is Editor.SelectedObjectChangedEvent) { // Disable events _lockList = true; @@ -70,31 +92,52 @@ obj is Editor.ObjectChangedEvent || lstObjects.ClearSelection(); if (_editor.SelectedObject?.Room == _editor.SelectedRoom) + SelectObjectInList(_editor.SelectedObject); + + _lockList = false; + } + } + + private void RebuildObjectList() + { + if (_editor.SelectedRoom == null) + return; + + _lockList = true; + + var currentObject = lstObjects.SelectedItems.Count > 0 ? lstObjects.SelectedItem.Tag : null; + + // Build in a detached collection so DarkListView processes all items in one go. + var allItems = new ObservableCollection(); + foreach (var o in _editor.SelectedRoom.Objects) + allItems.Add(new DarkListItem(o.ToShortString()) { Tag = o }); + + foreach (var o in _editor.SelectedRoom.GhostBlocks) + allItems.Add(new DarkListItem(o.ToShortString()) { Tag = o }); + + lstObjects.Items = allItems; + + // Restore selection. + for (int i = 0; i < lstObjects.Items.Count; i++) + if (lstObjects.Items[i].Tag == currentObject) { - if (_editor.SelectedObject is PositionBasedObjectInstance) - { - var o = _editor.SelectedObject as PositionBasedObjectInstance; - var entry = lstObjects.Items.FirstOrDefault(t => t.Tag == o); - if (entry != null) - { - lstObjects.SelectItem(lstObjects.Items.IndexOf(entry)); - lstObjects.EnsureVisible(); - } - } - else if (_editor.SelectedObject is GhostBlockInstance) - { - var o = _editor.SelectedObject as GhostBlockInstance; - var entry = lstObjects.Items.FirstOrDefault(t => t.Tag == o); - if (entry != null) - { - lstObjects.SelectItem(lstObjects.Items.IndexOf(entry)); - lstObjects.EnsureVisible(); - } - } + lstObjects.SelectItem(i); + break; } - // Reenable events - _lockList = false; + _lockList = false; + } + + private void SelectObjectInList(ObjectInstance obj) + { + if (obj is PositionBasedObjectInstance || obj is GhostBlockInstance) + { + var entry = lstObjects.Items.FirstOrDefault(t => t.Tag == obj); + if (entry != null) + { + lstObjects.SelectItem(lstObjects.Items.IndexOf(entry)); + lstObjects.EnsureVisible(); + } } } diff --git a/TombEditor/ToolWindows/SectorOptions.cs b/TombEditor/ToolWindows/SectorOptions.cs index 376052a57f..0682cc1d7f 100644 --- a/TombEditor/ToolWindows/SectorOptions.cs +++ b/TombEditor/ToolWindows/SectorOptions.cs @@ -80,7 +80,7 @@ obj is Editor.GameVersionChangedEvent || butClimbNegativeZ.Enabled = climbingSupported; butClimbPositiveX.Enabled = climbingSupported; butClimbPositiveZ.Enabled = climbingSupported; - butMonkey.Enabled = isTR345; + butMonkey.Enabled = _editor.Level.Settings.GameVersion.SupportsMonkeySwing(); butFlagBeetle.Enabled = isTR345; butFlagTriggerTriggerer.Enabled = isTR345; diff --git a/TombEditor/Undo.cs b/TombEditor/Undo.cs index 6fa8f28315..6c08cded3a 100644 --- a/TombEditor/Undo.cs +++ b/TombEditor/Undo.cs @@ -86,7 +86,7 @@ public class AddRemoveObjectUndoInstance : EditorUndoRedoInstance private PositionBasedObjectInstance UndoObject; private bool Created; - public AddRemoveObjectUndoInstance(EditorUndoManager parent, PositionBasedObjectInstance obj, bool created) : base(parent, obj.Room) + public AddRemoveObjectUndoInstance(EditorUndoManager parent, PositionBasedObjectInstance obj, bool created, Room room = null) : base(parent, room ?? obj.Room) { Created = created; UndoObject = obj; @@ -187,7 +187,12 @@ public TransformObjectUndoInstance(EditorUndoManager parent, PositionBasedObject // Rebuild lighting! if (UndoObject is LightInstance) - Room.BuildGeometry(); + { + if (Parent.Editor.ShouldRelight) + Room.RebuildLighting(Parent.Editor.Configuration.Rendering3D_HighQualityLightPreview); + else + Room.PendingRelight = true; + } // Move origin of object group, if it contains object if (Parent.Editor.SelectedObject is ObjectGroup) @@ -401,12 +406,12 @@ public GeometryUndoInstance(EditorUndoManager parent, Room room) : base(parent, for (int z = Area.Y0, j = 0; z < Area.Y1; z++, j++) Room.Sectors[x, z].ReplaceGeometry(Parent.Editor.Level, Sectors[i, j]); - Room.BuildGeometry(); + Room.Rebuild(parent.Editor.ShouldRelight, parent.Editor.Configuration.Rendering3D_HighQualityLightPreview); Parent.Editor.RoomGeometryChange(Room); Parent.Editor.RoomSectorPropertiesChange(Room); var relevantRooms = room.Portals.Select(p => p.AdjoiningRoom).Distinct(); - Parallel.ForEach(relevantRooms, r => r.BuildGeometry()); - + Parallel.ForEach(relevantRooms, r => r.Rebuild(parent.Editor.ShouldRelight, parent.Editor.Configuration.Rendering3D_HighQualityLightPreview)); + foreach (Room relevantRoom in relevantRooms) Parent.Editor.RoomGeometryChange(relevantRoom); diff --git a/TombEditor/ViewModels/ContentBrowserViewModel.cs b/TombEditor/ViewModels/ContentBrowserViewModel.cs new file mode 100644 index 0000000000..c606d1b352 --- /dev/null +++ b/TombEditor/ViewModels/ContentBrowserViewModel.cs @@ -0,0 +1,864 @@ +#nullable enable + +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using System; +using System.Collections; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using System.Windows.Data; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using TombLib.LevelData; +using TombLib.Utils; +using TombLib.Wad; +using TombLib.Wad.Catalog; +using TombLib.WPF.Services; +using TombLib.WPF.Services.Abstract; + +namespace TombEditor.ViewModels; + +/// +/// Categories for filtering assets in the Content Browser. +/// +public enum AssetCategory +{ + All, + Moveables, + Statics, + ImportedGeometry +} + +/// +/// Represents a single entry in the filter combobox. +/// Can be a type filter (All/Moveables/Statics/ImportedGeometry), +/// a category filter (from TrCatalog), or a visual splitter. +/// +public sealed class FilterOption +{ + /// + /// Display name shown in the combobox. + /// + public string DisplayName { get; } + + /// + /// True if this is a type-based filter (All, Moveables, Statics, ImportedGeometry). + /// + public bool IsTypeFilter { get; } + + /// + /// True if this is a non-selectable visual separator. + /// + public bool IsSplitter { get; } + + /// + /// True if this is the special "Favorites" filter. + /// + public bool IsFavoritesFilter { get; } + + /// + /// The type filter value, only meaningful when IsTypeFilter is true. + /// + public AssetCategory TypeFilter { get; } + + /// + /// The category name for category-based filtering. + /// Only meaningful when IsTypeFilter is false and IsSplitter is false. + /// + public string CategoryFilter { get; } + + private FilterOption(string displayName, bool isTypeFilter, bool isSplitter, bool isFavoritesFilter, AssetCategory typeFilter, string categoryFilter) + { + DisplayName = displayName; + IsTypeFilter = isTypeFilter; + IsSplitter = isSplitter; + IsFavoritesFilter = isFavoritesFilter; + TypeFilter = typeFilter; + CategoryFilter = categoryFilter; + } + + public static FilterOption CreateTypeFilter(AssetCategory category, string displayName) + => new(displayName, true, false, false, category, string.Empty); + + public static FilterOption CreateSplitter() + => new(string.Empty, false, true, false, AssetCategory.All, string.Empty); + + public static FilterOption CreateCategoryFilter(string categoryName) + => new(categoryName, false, false, false, AssetCategory.All, categoryName); + + public static FilterOption CreateFavoritesFilter(string displayName) + => new(displayName, false, false, true, AssetCategory.All, string.Empty); + + public override string ToString() => DisplayName; +} + +/// +/// ViewModel representing a single asset item in the Content Browser. +/// +public partial class AssetItemViewModel : ObservableObject +{ + /// + /// The underlying WAD object (WadMoveable, WadStatic, or ImportedGeometry). + /// + public IWadObject WadObject { get; } + + // Display name of the asset. + public string Name { get; } + + // Category this asset belongs to. + public AssetCategory Category { get; } + + // Localized category display name for grouping. + public string CategoryName { get; } + + // Name/path of the WAD file this asset was loaded from. + public string WadSource { get; } + + // Whether this asset exists in multiple WAD files. + public bool IsInMultipleWads { get; } + + // Catalog category string from TrCatalog (e.g. "Enemies", "Player"). + // May contain multiple values for special cases like "Shatterable". + public string CatalogCategory { get; } + + // Combined CategoryName + CatalogCategory for tooltip display. + public string CategoryDisplayText => string.IsNullOrEmpty(CatalogCategory) ? CategoryName : $"{CategoryName}, {CatalogCategory}"; + + // All effective categories including primary and any synthetic ones. + public List EffectiveCategories { get; } = new(); + + // Color brush for placeholder thumbnail based on category. + public SolidColorBrush ThumbnailBrush { get; } + + // Initials shown on the placeholder thumbnail. + public string Initials { get; } + + // Sort order for type grouping (Moveables=0, Statics=1, ImportedGeometry=2). + public int CategoryOrder => Category switch + { + AssetCategory.Moveables => 0, + AssetCategory.Statics => 1, + AssetCategory.ImportedGeometry => 2, + _ => 3 + }; + + // Unique key used for favorites persistence. + public string FavoriteKey { get; } + + // Whether this item is marked as a favorite. + [ObservableProperty] + private bool _isFavorite; + + // Backing field for thumbnail property. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasThumbnail))] + private BitmapSource? _thumbnail; + + // Whether a rendered thumbnail has been set. + public bool HasThumbnail => Thumbnail is not null; + + // True while covered by rubber-band selection; purely visual feedback. + [ObservableProperty] + private bool _isRubberBandSelected; + + // Unique cache key for this asset's thumbnail. + public string CacheKey { get; } + + private static readonly SolidColorBrush MoveableBrush = new(Color.FromRgb(0x4B, 0x6E, 0xAF)); + private static readonly SolidColorBrush StaticBrush = new(Color.FromRgb(0x5A, 0x8C, 0x46)); + private static readonly SolidColorBrush ImportedGeometryBrush = new(Color.FromRgb(0xC0, 0x7B, 0x38)); + + static AssetItemViewModel() + { + MoveableBrush.Freeze(); + StaticBrush.Freeze(); + ImportedGeometryBrush.Freeze(); + } + + public AssetItemViewModel(IWadObject wadObject, string name, AssetCategory category, string categoryName, string wadSource, + bool isInMultipleWads, string catalogCategory = "", string fileVersion = "") + { + WadObject = wadObject; + Name = name; + Category = category; + CategoryName = categoryName; + WadSource = wadSource; + IsInMultipleWads = isInMultipleWads; + CatalogCategory = catalogCategory; + + ThumbnailBrush = category switch + { + AssetCategory.Moveables => MoveableBrush, + AssetCategory.Statics => StaticBrush, + AssetCategory.ImportedGeometry => ImportedGeometryBrush, + _ => MoveableBrush + }; + + Initials = BuildInitials(name); + CacheKey = BuildCacheKey(wadObject, category, fileVersion); + FavoriteKey = BuildFavoriteKey(wadObject, category); + } + + public static string BuildFavoriteKey(IWadObject wadObject, AssetCategory category) + { + string prefix = category.ToString(); + + if (wadObject.Id is WadMoveableId movId) + return $"{prefix}_{movId.TypeId}"; + + if (wadObject.Id is WadStaticId statId) + return $"{prefix}_{statId.TypeId}"; + + if (wadObject is ImportedGeometry geo) + return $"{prefix}_{geo.Info.Name}_{geo.Info.Path}"; + + return $"{prefix}_{wadObject.GetHashCode()}"; + } + + private static string BuildCacheKey(IWadObject wadObject, AssetCategory category, string fileVersion = "") + { + string prefix = category.ToString(); + var versionSuffix = string.IsNullOrEmpty(fileVersion) ? string.Empty : $"_{fileVersion}"; + + if (wadObject.Id is not null) + return $"{prefix}_{wadObject.Id}{versionSuffix}"; + + if (wadObject is ImportedGeometry geo) + return $"{prefix}_{geo.GetHashCode()}{versionSuffix}"; + + return $"{prefix}_{wadObject.GetHashCode()}{versionSuffix}"; + } + + // Converts an ImageC (BGRA byte array) to a frozen WPF BitmapSource. + public static BitmapSource? ImageCToBitmapSource(ImageC image) + { + if (image.Width == 0 || image.Height == 0) + return null; + + byte[] data = image.ToByteArray(); + int stride = image.Width * 4; // BGRA = 4 bytes per pixel. + var bmp = BitmapSource.Create(image.Width, image.Height, 96, 96, PixelFormats.Bgra32, null, data, stride); + bmp.Freeze(); + return bmp; + } + + private static string BuildInitials(string name) + { + if (string.IsNullOrWhiteSpace(name)) + return "?"; + + var cleaned = name.Trim(); + + // Skip leading "(number) " prefix, e.g. "(100) WOLF" -> "WOLF". + if (cleaned.Length > 0 && cleaned[0] == '(') + { + int closeIdx = cleaned.IndexOf(')'); + + if (closeIdx > 0 && closeIdx + 1 < cleaned.Length) + cleaned = cleaned[(closeIdx + 1)..].TrimStart(); + } + + if (string.IsNullOrEmpty(cleaned)) + return "?"; + + // Use the first letter of the cleaned name. + return cleaned[0].ToString().ToUpperInvariant(); + } +} + +/// +/// Main ViewModel for the Content Browser tool window. +/// Manages the collection of assets, search/filtering, and type-based sorting. +/// +public partial class ContentBrowserViewModel : ObservableObject +{ + private readonly ILocalizationService _localizationService; + + // Full collection of all asset items. + public ObservableCollection AllItems { get; } = new(); + + // Filtered view of the items. + public ICollectionView FilteredItems { get; } + + // Search text for filtering assets by name. + [ObservableProperty] + private string _searchText = string.Empty; + + // Currently selected filter option (type or category). + [ObservableProperty] + private FilterOption? _selectedFilter; + + // Currently selected asset item. + [ObservableProperty] + private AssetItemViewModel? _selectedItem; + + // WAD source info for the selected item. + [ObservableProperty] + private string _selectedItemWadInfo = string.Empty; + + // Tile size (width) in pixels. Controls the grid tile dimensions. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(TileHeight))] + [NotifyPropertyChangedFor(nameof(ThumbSize))] + private double _tileWidth = 88; + + // Computed tile height: thumbnail size + 4px margin + fixed label row height. + public double TileHeight => ThumbSize + 4 + 22; + + // Computed thumbnail square size based on tile width. + public double ThumbSize => TileWidth * 0.78; + + // Minimum tile width. + public double MinTileWidth { get; } = 60; + + // Maximum tile width. + public double MaxTileWidth { get; } = 180; + + // Cached count of items currently visible after filtering. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(FormattedItemCount))] + private int _itemCount; + + // Formatted item count string for display. + public string FormattedItemCount => _localizationService.Format("ItemCount", ItemCount); + + // Available filter options (type filters + optional splitter + category filters). + public ObservableCollection FilterOptions { get; } = new(); + + // The default "All" filter option. + private readonly FilterOption _allFilter; + + // Cached lowered search text to avoid per-item allocation in FilterPredicate. + private string _searchTextLower = string.Empty; + + public ContentBrowserViewModel(ILocalizationService? localizationService = null) + { + _localizationService = ServiceLocator.ResolveService(localizationService) + .WithKeysFor(this); + + _allFilter = FilterOption.CreateTypeFilter(AssetCategory.All, _localizationService["FilterAll"]); + + FilteredItems = CollectionViewSource.GetDefaultView(AllItems); + FilteredItems.Filter = FilterPredicate; + + // Initialize default type filters. + FilterOptions.Add(_allFilter); + FilterOptions.Add(FilterOption.CreateTypeFilter(AssetCategory.Moveables, _localizationService["FilterMoveables"])); + FilterOptions.Add(FilterOption.CreateTypeFilter(AssetCategory.Statics, _localizationService["FilterStatics"])); + FilterOptions.Add(FilterOption.CreateTypeFilter(AssetCategory.ImportedGeometry, _localizationService["FilterImportedGeometry"])); + FilterOptions.Add(FilterOption.CreateSplitter()); + FilterOptions.Add(FilterOption.CreateFavoritesFilter(_localizationService["FilterFavorites"])); + + SelectedFilter = _allFilter; + + // Use a custom comparer for natural (numeric-aware) sorting of asset names. + // SortDescriptions uses lexicographic order which sorts (1), (10), (100), (2)... + if (FilteredItems is ListCollectionView lcv) + { + lcv.CustomSort = new AssetItemComparer(); + } + else + { + // Fallback for non-list views (shouldn't happen with ObservableCollection). + FilteredItems.SortDescriptions.Add(new SortDescription(nameof(AssetItemViewModel.CategoryOrder), ListSortDirection.Ascending)); + FilteredItems.SortDescriptions.Add(new SortDescription(nameof(AssetItemViewModel.Name), ListSortDirection.Ascending)); + } + } + + // Refreshes the filtered view and updates the cached item count. + private void RefreshFilteredItems() + { + FilteredItems.Refresh(); + ItemCount = FilteredItems.Cast().Count(); + } + + partial void OnSearchTextChanged(string value) + { + _searchTextLower = value.ToLowerInvariant(); + RefreshFilteredItems(); + } + + partial void OnSelectedFilterChanged(FilterOption? value) + { + // Prevent selecting splitter items. + if (value is not null && value.IsSplitter) + { + SelectedFilter = _allFilter; + return; + } + + RefreshFilteredItems(); + } + + partial void OnSelectedItemChanged(AssetItemViewModel? value) + { + if (value is not null) + { + SelectedItemWadInfo = value.IsInMultipleWads + ? _localizationService.Format("WadSourceMultiple", value.WadSource) + : _localizationService.Format("WadSourceSingle", value.WadSource); + } + else + { + SelectedItemWadInfo = string.Empty; + } + + SelectedItemChanged?.Invoke(this, value); + } + + // Event raised when the selected item changes; used by WinForms host to update Editor state. + public event EventHandler? SelectedItemChanged; + + // Event raised when the selected items change (multi-selection). + public event EventHandler>? SelectedItemsChanged; + + // Event raised when a drag-drop operation is requested; carries selected items. + public event EventHandler>? DragDropRequested; + + // Event raised when thumbnails need rendering; host handles it on UI thread. + public event EventHandler? ThumbnailRenderRequested; + + // Event raised when the user requests to locate a specific item (Alt+click). + public event EventHandler? LocateItemRequested; + + // Event raised when the user requests to add/place the selected item. + public event EventHandler? AddItemRequested; + + // Event raised when the user requests to add a new WAD file. + public event EventHandler? AddWadRequested; + + // Event raised when files are dropped from Windows Explorer onto the Content Browser. + public event EventHandler? FilesDropped; + + // Event raised when a favorite is toggled; host persists the change to LevelSettings. + public event EventHandler? FavoriteToggled; + + // Current list of all selected items (for multi-selection). + public IReadOnlyList SelectedItems { get; private set; } = Array.Empty(); + + // Updates the selected items from the view's ListBox selection. + public void UpdateSelectedItems(IReadOnlyList items) + { + SelectedItems = items; + + var first = items.Count > 0 ? items[0] : null; + + if (first != SelectedItem) + SelectedItem = first; + + SelectedItemsChanged?.Invoke(this, items); + } + + // In-memory thumbnail cache keyed by CacheKey. + private readonly Dictionary _thumbnailCache = new(); + + // Stored for use in FilterPredicate (set during RefreshAssets). + private TRVersion.Game _gameVersion; + + private bool _hideInternalObjects; + + // Whether any WADs are loaded in the level. + [ObservableProperty] + private bool _hasLoadedWads; + + /// + /// Refreshes all assets from the current level settings. + /// Builds moveable and static item lists in parallel for maximum performance. + /// Also scans for catalog categories and populates filter options. + /// + public void RefreshAssets(LevelSettings settings, bool hideInternalObjects = false) + { + var previousSelection = SelectedItem?.WadObject; + var previousFilterName = SelectedFilter?.DisplayName; + + var gameVersion = settings.GameVersion; + bool isTombEngine = gameVersion == TRVersion.Game.TombEngine; + + _gameVersion = gameVersion; + _hideInternalObjects = hideInternalObjects; + + // Cache localized category names for use in parallel tasks. + string moveablesCategory = _localizationService["CategoryMoveables"]; + string staticsCategory = _localizationService["CategoryStatics"]; + string importedGeometryCategory = _localizationService["CategoryImportedGeometry"]; + + // Fetch all objects upfront (these iterate wad files). + var allMoveables = settings.WadGetAllMoveables(); + var allStatics = settings.WadGetAllStatics(); + + // Pre-compute wad file versions for version-aware cache keys. + // Read-only in parallel tasks below, so no locking is needed. + var wadFileVersions = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var wad in settings.Wads) + { + if (wad.Wad is not null && !string.IsNullOrEmpty(wad.Path)) + wadFileVersions[wad.Path] = GetFileVersion(settings.MakeAbsolute(wad.Path)); + } + + // Build moveable and static items in parallel using thread-safe collections. + // Each item construction is independent - name resolution, wad source lookup. + // initials, and cache key are all pure/readonly operations. + var moveableItems = new ConcurrentBag(); + var staticItems = new ConcurrentBag(); + + var moveableTask = Task.Run(() => + { + Parallel.ForEach(allMoveables, kvp => + { + var moveable = kvp.Value; + string name = moveable.ToString(gameVersion); + + var itemType = new ItemType(moveable.Id, settings); + var wad = settings.WadTryGetWad(itemType, out bool multiple); + + string wadSource = wad is not null ? Path.GetFileName(wad.Path) : "Unknown"; + var fileVersion = wad is not null && wadFileVersions.TryGetValue(wad.Path, out var wadVer) ? wadVer : string.Empty; + string catalogCategory = TrCatalog.GetMoveableCategory(gameVersion, moveable.Id.TypeId); + + var item = new AssetItemViewModel(moveable, name, AssetCategory.Moveables, moveablesCategory, wadSource, multiple, catalogCategory, fileVersion); + + // Add the primary catalog category. + if (!string.IsNullOrEmpty(catalogCategory)) + item.EffectiveCategories.Add(catalogCategory); + + moveableItems.Add(item); + }); + }); + + var staticTask = Task.Run(() => + { + Parallel.ForEach(allStatics, kvp => + { + var staticMesh = kvp.Value; + string name = staticMesh.ToString(gameVersion); + + var itemType = new ItemType(staticMesh.Id, settings); + var wad = settings.WadTryGetWad(itemType, out bool multiple); + string wadSource = wad is not null ? Path.GetFileName(wad.Path) : "Unknown"; + var fileVersion = wad is not null && wadFileVersions.TryGetValue(wad.Path, out var wadVer) ? wadVer : string.Empty; + + string catalogCategory = TrCatalog.GetStaticCategory(gameVersion, staticMesh.Id.TypeId); + + var item = new AssetItemViewModel(staticMesh, name, AssetCategory.Statics, staticsCategory, wadSource, multiple, catalogCategory, fileVersion); + + // Add the primary catalog category. + if (!string.IsNullOrEmpty(catalogCategory)) + item.EffectiveCategories.Add(catalogCategory); + + // Determine if this static is shatterable. + // WadStatic.Shatter flag takes priority for TombEngine, otherwise fall back to TrCatalog.IsStaticShatterable. + bool isShatterable = TrCatalog.IsStaticShatterable(gameVersion, staticMesh.Id.TypeId); + + if (isTombEngine && staticMesh.Shatter) + isShatterable = true; + + if (isShatterable && !item.EffectiveCategories.Contains("Shatterable")) + item.EffectiveCategories.Add("Shatterable"); + + // Also tolerate catalog category string "Shatterable" even if no shatterable flag. + if (string.Equals(catalogCategory, "Shatterable", StringComparison.OrdinalIgnoreCase) && !item.EffectiveCategories.Contains("Shatterable")) + item.EffectiveCategories.Add("Shatterable"); + + staticItems.Add(item); + }); + }); + + // Build imported geometry items (lightweight - no parallelization needed). + var geoItems = new List(); + + foreach (var geo in settings.ImportedGeometries) + { + if (geo.LoadException is not null) + continue; + + string name = string.IsNullOrEmpty(geo.Info.Name) ? Path.GetFileNameWithoutExtension(geo.Info.Path) ?? "Unnamed" : geo.Info.Name; + string wadSource = !string.IsNullOrEmpty(geo.Info.Path) ? Path.GetFileName(geo.Info.Path) : "Inline"; + var fileVersion = !string.IsNullOrEmpty(geo.Info.Path) ? GetFileVersion(settings.MakeAbsolute(geo.Info.Path)) : string.Empty; + + geoItems.Add(new AssetItemViewModel(geo, name, AssetCategory.ImportedGeometry, importedGeometryCategory, wadSource, false, string.Empty, fileVersion)); + } + + // Wait for parallel moveable and static building to complete. + Task.WaitAll(moveableTask, staticTask); + + // Batch-populate the ObservableCollection (must happen on UI thread). + // Detach filter during bulk insert to avoid per-item filtering overhead. + FilteredItems.Filter = null; + AllItems.Clear(); + + foreach (var item in moveableItems) + AllItems.Add(item); + foreach (var item in staticItems) + AllItems.Add(item); + foreach (var item in geoItems) + AllItems.Add(item); + + FilteredItems.Filter = FilterPredicate; + + // Track whether any WADs are loaded. + HasLoadedWads = AllItems.Count > 0; + + // Apply favorite state from saved settings. + foreach (var item in AllItems) + { + if (settings.Favorites.Contains(item.FavoriteKey)) + item.IsFavorite = true; + } + + // Build category list from all moveable and static items. + var allCategories = new SortedSet(StringComparer.OrdinalIgnoreCase); + + foreach (var item in AllItems) + { + foreach (var cat in item.EffectiveCategories) + { + if (!string.IsNullOrEmpty(cat)) + allCategories.Add(cat); + } + } + + // Rebuild filter options. + FilterOptions.Clear(); + FilterOptions.Add(_allFilter); + FilterOptions.Add(FilterOption.CreateTypeFilter(AssetCategory.Moveables, _localizationService["FilterMoveables"])); + FilterOptions.Add(FilterOption.CreateTypeFilter(AssetCategory.Statics, _localizationService["FilterStatics"])); + FilterOptions.Add(FilterOption.CreateTypeFilter(AssetCategory.ImportedGeometry, _localizationService["FilterImportedGeometry"])); + FilterOptions.Add(FilterOption.CreateSplitter()); + FilterOptions.Add(FilterOption.CreateFavoritesFilter(_localizationService["FilterFavorites"])); + + if (allCategories.Count > 0) + { + foreach (var cat in allCategories) + FilterOptions.Add(FilterOption.CreateCategoryFilter(cat)); + } + + // Restore previous filter selection or default to All. + SelectedFilter = FilterOptions.FirstOrDefault(f => + f.DisplayName == previousFilterName && !f.IsSplitter) ?? _allFilter; + + RefreshFilteredItems(); + + // Try to restore previous selection. + if (previousSelection is not null) + { + SelectedItem = AllItems.FirstOrDefault(i => ReferenceEquals(i.WadObject, previousSelection)) + ?? AllItems.FirstOrDefault(i => i.WadObject.Id is not null && previousSelection.Id is not null + && i.WadObject.Id.GetType() == previousSelection.Id.GetType() + && i.WadObject.Id.CompareTo(previousSelection.Id) == 0); + } + + // Apply cached thumbnails and request rendering for uncached items. + bool needsRender = false; + + foreach (var item in AllItems) + { + if (_thumbnailCache.TryGetValue(item.CacheKey, out var cached)) + item.Thumbnail = cached; + else + needsRender = true; + } + + if (needsRender) + ThumbnailRenderRequested?.Invoke(this, EventArgs.Empty); + } + + /// + /// Called by the host to set a rendered thumbnail for an item and cache it. + /// + public void SetThumbnail(AssetItemViewModel item, BitmapSource? thumbnail) + { + if (thumbnail is null) + return; + + if (!thumbnail.IsFrozen) + thumbnail.Freeze(); + + item.Thumbnail = thumbnail; + _thumbnailCache[item.CacheKey] = thumbnail; + } + + /// + /// Returns true if a cached thumbnail exists for this item. + /// + public bool HasCachedThumbnail(AssetItemViewModel item) + { + return _thumbnailCache.ContainsKey(item.CacheKey); + } + + /// + /// Clears the entire thumbnail cache, forcing re-render on next refresh. + /// + public void InvalidateThumbnailCache() + { + _thumbnailCache.Clear(); + + foreach (var item in AllItems) + item.Thumbnail = null; + } + + private static string GetFileVersion(string absolutePath) + { + try + { + return File.GetLastWriteTimeUtc(absolutePath).Ticks.ToString(); + } + catch + { + return "0"; + } + } + + /// + /// Gets all items that still need thumbnails rendered. + /// + public IEnumerable GetItemsNeedingThumbnails() + { + return AllItems.Where(i => i.Thumbnail is null); + } + + /// + /// Requests a drag-drop operation for a list of selected items. + /// + public void RequestDragDrop(IReadOnlyList items) + { + if (items.Count > 0) + DragDropRequested?.Invoke(this, items); + } + + /// + /// Requests locating a specific item in the level (Alt+click). + /// + public void RequestLocateItem(AssetItemViewModel item) + { + LocateItemRequested?.Invoke(this, item); + } + + [RelayCommand] + private void ClearSearch() + { + SearchText = string.Empty; + } + + [RelayCommand] + private void LocateItem() + { + if (SelectedItem is not null) + LocateItemRequested?.Invoke(this, SelectedItem); + } + + [RelayCommand] + private void AddItem() + { + if (SelectedItem is not null) + AddItemRequested?.Invoke(this, EventArgs.Empty); + } + + [RelayCommand] + private void AddWad() + { + AddWadRequested?.Invoke(this, EventArgs.Empty); + } + + // Toggles the favorite state of an item and notifies the host. + public void ToggleFavorite(AssetItemViewModel item) + { + item.IsFavorite = !item.IsFavorite; + FavoriteToggled?.Invoke(this, item); + } + + // Routes a file drop from the view to subscribing hosts. + public void HandleFileDrop(string[] files) + { + FilesDropped?.Invoke(this, files); + } + + private bool FilterPredicate(object obj) + { + if (obj is not AssetItemViewModel item) + return false; + + // Hide internal/engine-only moveables when configured + if (_hideInternalObjects && + item.Category == AssetCategory.Moveables && + item.WadObject is WadMoveable mov && + TrCatalog.IsHidden(_gameVersion, mov.Id.TypeId)) + { + return false; + } + + var filter = SelectedFilter; + + // Apply type or category filter + if (filter?.IsSplitter == false) + { + if (filter.IsFavoritesFilter) + { + if (!item.IsFavorite) + return false; + } + else if (filter.IsTypeFilter) + { + // Type filter: filter by asset type (All shows everything) + if (filter.TypeFilter != AssetCategory.All && item.Category != filter.TypeFilter) + return false; + } + else + { + // Category filter: show all items matching this category regardless of type + if (!item.EffectiveCategories.Contains(filter.CategoryFilter, StringComparer.OrdinalIgnoreCase)) + return false; + } + } + + // Text search + if (!string.IsNullOrWhiteSpace(SearchText)) + { + // Exact substring match + if (item.Name.Contains(SearchText, StringComparison.OrdinalIgnoreCase)) + return true; + + if (item.WadSource.Contains(SearchText, StringComparison.OrdinalIgnoreCase)) + return true; + + // Fuzzy match using Levenshtein distance (for queries with 3+ chars) + if (_searchTextLower.Length >= 3) + { + int distance = Levenshtein.DistanceSubstring( + item.Name.ToLowerInvariant(), _searchTextLower, out _); + + if (distance < 2) + return true; + } + + return false; + } + + return true; + } + + /// + /// Comparer for sorting asset items by type (CategoryOrder) then by name using + /// natural numeric ordering so that e.g. (2) sorts before (10). + /// + private sealed class AssetItemComparer : IComparer + { + public int Compare(object? x, object? y) + { + if (x is not AssetItemViewModel a || y is not AssetItemViewModel b) + return 0; + + int catCmp = a.CategoryOrder.CompareTo(b.CategoryOrder); + + if (catCmp != 0) + return catCmp; + + return NaturalComparer.Do(a.Name, b.Name); + } + } +} diff --git a/TombEditor/ViewModels/ObjectBrushToolboxViewModel.cs b/TombEditor/ViewModels/ObjectBrushToolboxViewModel.cs new file mode 100644 index 0000000000..b89f777425 --- /dev/null +++ b/TombEditor/ViewModels/ObjectBrushToolboxViewModel.cs @@ -0,0 +1,199 @@ +#nullable enable + +using CommunityToolkit.Mvvm.ComponentModel; +using System; +using TombLib.LevelData; + +namespace TombEditor.ViewModels; + +/// +/// ViewModel for the Object Brush Toolbox panel. +/// Manages brush configuration settings (radius, density, rotation, scale, etc.) +/// and determines which controls are enabled based on the current tool. +/// +public partial class ObjectBrushToolboxViewModel : ObservableObject +{ + private readonly Editor _editor; + private bool _isLoadingSettings; + private bool _isSavingSettings; + + public ObjectBrushToolboxViewModel() + { + _editor = Editor.Instance; + _editor.EditorEventRaised += OnEditorEventRaised; + LoadSettings(); + } + + public void Cleanup() + { + _editor.EditorEventRaised -= OnEditorEventRaised; + } + + #region Settings Properties + + // Brush radius in sectors (displayed in UI). Stored in config as world units. + [ObservableProperty] private double _radius = 0.5; + [ObservableProperty] private double _density = 1.0; + [ObservableProperty] private double _rotation; + [ObservableProperty] private bool _isOrthogonal; + [ObservableProperty] private bool _isRandomRotation; + [ObservableProperty] private bool _isFollowMouseDirection; + [ObservableProperty] private bool _isRandomScale; + [ObservableProperty] private double _scaleMin = 0.8; + [ObservableProperty] private double _scaleMax = 1.2; + [ObservableProperty] private bool _isFitToGround; + [ObservableProperty] private bool _isAlignToGrid; + [ObservableProperty] private bool _isPlaceInAdjacentRooms; + + #endregion Settings Properties + + #region Enabled States + + [ObservableProperty] private bool _isRadiusEnabled = true; + [ObservableProperty] private bool _isDensityEnabled; + [ObservableProperty] private bool _isRotationEnabled; + [ObservableProperty] private bool _isOrthogonalEnabled; + [ObservableProperty] private bool _isRandomRotationEnabled; + [ObservableProperty] private bool _isFollowMouseDirectionEnabled; + [ObservableProperty] private bool _isRandomScaleEnabled; + [ObservableProperty] private bool _isScaleMinEnabled; + [ObservableProperty] private bool _isScaleMaxEnabled; + [ObservableProperty] private bool _isFitToGroundEnabled; + [ObservableProperty] private bool _isAlignToGridEnabled; + [ObservableProperty] private bool _isAdjacentRoomsEnabled; + + #endregion Enabled States + + #region Property Change Handlers + + partial void OnRadiusChanged(double value) => SaveSettingsIfNotLoading(); + partial void OnDensityChanged(double value) => SaveSettingsIfNotLoading(); + partial void OnRotationChanged(double value) => SaveSettingsIfNotLoading(); + partial void OnIsOrthogonalChanged(bool value) => SaveSettingsIfNotLoading(); + partial void OnScaleMinChanged(double value) => SaveSettingsIfNotLoading(); + partial void OnScaleMaxChanged(double value) => SaveSettingsIfNotLoading(); + partial void OnIsFitToGroundChanged(bool value) => SaveSettingsIfNotLoading(); + partial void OnIsAlignToGridChanged(bool value) => SaveSettingsIfNotLoading(); + partial void OnIsPlaceInAdjacentRoomsChanged(bool value) => SaveSettingsIfNotLoading(); + + partial void OnIsRandomRotationChanged(bool value) + { + SaveSettingsIfNotLoading(); + UpdateControlsForTool(); + } + + partial void OnIsFollowMouseDirectionChanged(bool value) + { + SaveSettingsIfNotLoading(); + UpdateControlsForTool(); + } + + partial void OnIsRandomScaleChanged(bool value) + { + SaveSettingsIfNotLoading(); + UpdateControlsForTool(); + } + + #endregion Property Change Handlers + + private void SaveSettingsIfNotLoading() + { + if (_isLoadingSettings) + return; + + SaveSettings(); + } + + private void LoadSettings() + { + _isLoadingSettings = true; + + var config = _editor.Configuration; + + Radius = Math.Clamp(config.ObjectBrush_Radius / Level.SectorSizeUnit, Controls.ObjectBrush.Constants.MinRadius, Controls.ObjectBrush.Constants.MaxRadius); + Density = Math.Clamp(config.ObjectBrush_Density, Controls.ObjectBrush.Constants.MinDensity, Controls.ObjectBrush.Constants.MaxDensity); + Rotation = Math.Clamp(config.ObjectBrush_Rotation, 0.0, 360.0); + IsOrthogonal = config.ObjectBrush_Orthogonal; + IsRandomRotation = config.ObjectBrush_RandomizeRotation; + IsFollowMouseDirection = config.ObjectBrush_FollowMouseDirection; + IsRandomScale = config.ObjectBrush_RandomizeScale; + ScaleMin = Math.Clamp(config.ObjectBrush_ScaleMin, 0.1, 10.0); + ScaleMax = Math.Clamp(config.ObjectBrush_ScaleMax, 0.1, 10.0); + IsFitToGround = config.ObjectBrush_FitToGround; + IsAlignToGrid = config.ObjectBrush_AlignToGrid; + IsPlaceInAdjacentRooms = config.ObjectBrush_PlaceInAdjacentRooms; + + _isLoadingSettings = false; + + UpdateControlsForTool(); + } + + private void SaveSettings() + { + _isSavingSettings = true; + + var config = _editor.Configuration; + + config.ObjectBrush_Radius = (float)Radius * Level.SectorSizeUnit; + config.ObjectBrush_Density = (float)Density; + config.ObjectBrush_Rotation = (float)Rotation; + config.ObjectBrush_Orthogonal = IsOrthogonal; + config.ObjectBrush_RandomizeRotation = IsRandomRotation; + config.ObjectBrush_FollowMouseDirection = IsFollowMouseDirection; + config.ObjectBrush_RandomizeScale = IsRandomScale; + config.ObjectBrush_ScaleMin = (float)ScaleMin; + config.ObjectBrush_ScaleMax = (float)ScaleMax; + config.ObjectBrush_FitToGround = IsFitToGround; + config.ObjectBrush_AlignToGrid = IsAlignToGrid; + config.ObjectBrush_PlaceInAdjacentRooms = IsPlaceInAdjacentRooms; + + _editor.ObjectBrushSettingsChange(); + + _isSavingSettings = false; + } + + private void UpdateControlsForTool() + { + if (_editor.Mode != EditorMode.ObjectPlacement) + return; + + var tool = _editor.Tool.Tool; + bool isBrush = tool == EditorToolType.Brush; + bool isPencil = tool == EditorToolType.Pencil; + bool isLine = tool == EditorToolType.Line; + bool isEraser = tool == EditorToolType.Eraser; + bool isFill = tool == EditorToolType.Fill; + bool isSelection = tool == EditorToolType.Selection; + + IsRadiusEnabled = !isFill && !isSelection; + IsDensityEnabled = isBrush || isEraser || isFill; + IsAdjacentRoomsEnabled = isBrush || isEraser || isPencil || isLine; + + bool allowRotation = isBrush || isPencil || isLine || isFill; + + IsOrthogonalEnabled = allowRotation; + IsRandomRotationEnabled = allowRotation && !isLine; + IsFollowMouseDirectionEnabled = isBrush || isPencil; + IsRotationEnabled = isLine || (allowRotation && !IsRandomRotation && !IsFollowMouseDirection); + + bool allowScale = isBrush || isPencil || isLine || isFill; + + IsFitToGroundEnabled = allowScale; + IsAlignToGridEnabled = isLine || isPencil; + IsRandomScaleEnabled = allowScale; + IsScaleMinEnabled = allowScale && IsRandomScale; + IsScaleMaxEnabled = allowScale && IsRandomScale; + } + + private void OnEditorEventRaised(IEditorEvent obj) + { + if (!_isSavingSettings && + obj is Editor.ConfigurationChangedEvent or + Editor.ObjectBrushSettingsChangedEvent) + LoadSettings(); + + if (obj is Editor.ModeChangedEvent or + Editor.ToolChangedEvent) + UpdateControlsForTool(); + } +} diff --git a/TombEditor/ViewModels/ToolBoxViewModel.cs b/TombEditor/ViewModels/ToolBoxViewModel.cs new file mode 100644 index 0000000000..bee28d2494 --- /dev/null +++ b/TombEditor/ViewModels/ToolBoxViewModel.cs @@ -0,0 +1,287 @@ +#nullable enable + +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using System; +using TombLib.Utils; +using TombLib.WPF.Services; +using TombLib.WPF.Services.Abstract; + +namespace TombEditor.ViewModels; + +/// +/// ViewModel for the ToolBox panel. +/// Manages tool selection state, mode-based visibility, and brush/texture settings. +/// Icon assignment, DPI measurement, and WinForms interop remain in code-behind. +/// +public partial class ToolBoxViewModel : ObservableObject +{ + private readonly Editor _editor; + private readonly ILocalizationService _localizationService; + + private const string GridPaintIconBase = "/TombEditor;component/Resources/icons_toolbox/toolbox_GridPaint"; + + public ToolBoxViewModel(ILocalizationService? localizationService = null) + { + _editor = Editor.Instance; + + _localizationService = ServiceLocator.ResolveService(localizationService) + .WithKeysFor(this); + + _editor.EditorEventRaised += OnEditorEventRaised; + Refresh(); + } + + public void Cleanup() + { + _editor.EditorEventRaised -= OnEditorEventRaised; + } + + #region Tool Checked States + + [ObservableProperty] private bool _isSelectionChecked; + [ObservableProperty] private bool _isObjectSelectionChecked; + [ObservableProperty] private bool _isBrushChecked; + [ObservableProperty] private bool _isPencilChecked; + [ObservableProperty] private bool _isLineChecked; + [ObservableProperty] private bool _isObjectDeselectionChecked; + [ObservableProperty] private bool _isFillChecked; + [ObservableProperty] private bool _isGroupChecked; + [ObservableProperty] private bool _isGridPaintChecked; + [ObservableProperty] private bool _isShovelChecked; + [ObservableProperty] private bool _isFlattenChecked; + [ObservableProperty] private bool _isSmoothChecked; + [ObservableProperty] private bool _isDragChecked; + [ObservableProperty] private bool _isRampChecked; + [ObservableProperty] private bool _isQuarterPipeChecked; + [ObservableProperty] private bool _isHalfPipeChecked; + [ObservableProperty] private bool _isBowlChecked; + [ObservableProperty] private bool _isPyramidChecked; + [ObservableProperty] private bool _isTerrainChecked; + [ObservableProperty] private bool _isPortalDiggerChecked; + [ObservableProperty] private bool _isObjectEraserChecked; + + // Object brush shapes + [ObservableProperty] private bool _isBrushShapeCircleChecked; + [ObservableProperty] private bool _isBrushShapeSquareChecked; + + // Special checked states (not derived from current tool type) + [ObservableProperty] private bool _isUVFixerChecked; + [ObservableProperty] private bool _isTextureEraserChecked; + [ObservableProperty] private bool _isTextureInvisibleChecked; + [ObservableProperty] private bool _isShowTexturesChecked; + + #endregion Tool Checked States + + #region Grid Size + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(GridPaintTooltip))] + [NotifyPropertyChangedFor(nameof(GridPaintIconSource))] + private PaintGridSize _gridSize = PaintGridSize.Grid2x2; + + public string GridPaintTooltip => GridSize switch + { + PaintGridSize.Grid2x2 => _localizationService.Format("GridPaint", "2x2"), + PaintGridSize.Grid3x3 => _localizationService.Format("GridPaint", "3x3"), + PaintGridSize.Grid4x4 => _localizationService.Format("GridPaint", "4x4"), + _ => _localizationService["GridPaint"] + }; + + public string GridPaintIconSource => GridSize switch + { + PaintGridSize.Grid2x2 => $"{GridPaintIconBase}2x2-16.png", + PaintGridSize.Grid3x3 => $"{GridPaintIconBase}3x3-16.png", + PaintGridSize.Grid4x4 => $"{GridPaintIconBase}4x4-16.png", + _ => $"{GridPaintIconBase}2x2-16.png" + }; + + #endregion Grid Size + + #region Mode Visibility + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsGeometryToolsVisible))] + [NotifyPropertyChangedFor(nameof(IsFaceEditToolsVisible))] + [NotifyPropertyChangedFor(nameof(IsObjectPlacementToolsVisible))] + [NotifyPropertyChangedFor(nameof(IsFillVisible))] + [NotifyPropertyChangedFor(nameof(IsSeparator2Visible))] + [NotifyPropertyChangedFor(nameof(IsToolboxVisible))] + private EditorMode _currentMode; + + public bool IsGeometryToolsVisible => CurrentMode == EditorMode.Geometry; + + public bool IsFaceEditToolsVisible => CurrentMode + is EditorMode.FaceEdit + or EditorMode.Lighting; + + public bool IsObjectPlacementToolsVisible => CurrentMode == EditorMode.ObjectPlacement; + + public bool IsFillVisible => IsFaceEditToolsVisible || IsObjectPlacementToolsVisible; + + public bool IsSeparator2Visible => !IsObjectPlacementToolsVisible; + + public bool IsToolboxVisible => CurrentMode + is EditorMode.FaceEdit + or EditorMode.Lighting + or EditorMode.Geometry + or EditorMode.ObjectPlacement; + + #endregion Mode Visibility + + #region Commands + + [RelayCommand] + private void SwitchTool(string toolName) + { + if (Enum.TryParse(toolName, out var toolType)) + _editor.Tool = CreateEditorTool(toolType); + } + + [RelayCommand] + private void SwitchToGridPaint() + { + _editor.Tool = CreateEditorTool(EditorToolType.GridPaint); + } + + [RelayCommand] + private void SetBrushShapeCircle() + { + _editor.Configuration.ObjectBrush_Shape = ObjectBrushShape.Circle; + _editor.ObjectBrushSettingsChange(); + } + + [RelayCommand] + private void SetBrushShapeSquare() + { + _editor.Configuration.ObjectBrush_Shape = ObjectBrushShape.Square; + _editor.ObjectBrushSettingsChange(); + } + + [RelayCommand] + private void ToggleShowTextures() + { + _editor.Configuration.ObjectBrush_ShowTextures = !_editor.Configuration.ObjectBrush_ShowTextures; + _editor.ObjectBrushSettingsChange(); + } + + [RelayCommand] + private void SetTextureEraser() + { + if (IsTextureEraserChecked) + { + OnPropertyChanged(nameof(IsTextureEraserChecked)); + return; + } + + _editor.SelectedTexture = TextureArea.None; + } + + [RelayCommand] + private void SetTextureInvisible() + { + if (IsTextureInvisibleChecked) + { + OnPropertyChanged(nameof(IsTextureInvisibleChecked)); + return; + } + + _editor.SelectedTexture = TextureArea.Invisible; + } + + [RelayCommand] + private void ToggleUVFixer() + { + _editor.Tool = CreateEditorTool(uvFixer: !_editor.Tool.TextureUVFixer); + } + + private EditorTool CreateEditorTool(EditorToolType? tool = null, bool? uvFixer = null) + { + var current = _editor.Tool; + + return new EditorTool + { + Tool = tool ?? current.Tool, + TextureUVFixer = uvFixer ?? current.TextureUVFixer, + GridSize = current.GridSize + }; + } + + #endregion Commands + + #region State Updates + + private void Refresh() + { + UpdateToolCheckedState(); + UpdateBrushSettings(); + UpdateTextureState(); + UpdateModeVisibility(); + } + + private void UpdateToolCheckedState() + { + var tool = _editor.Tool; + + IsSelectionChecked = tool.Tool == EditorToolType.Selection; + IsObjectSelectionChecked = tool.Tool == EditorToolType.ObjectSelection; + IsObjectDeselectionChecked = tool.Tool == EditorToolType.ObjectDeselection; + IsBrushChecked = tool.Tool == EditorToolType.Brush; + IsPencilChecked = tool.Tool == EditorToolType.Pencil; + IsLineChecked = tool.Tool == EditorToolType.Line; + IsFillChecked = tool.Tool == EditorToolType.Fill; + IsGroupChecked = tool.Tool == EditorToolType.Group; + IsGridPaintChecked = tool.Tool == EditorToolType.GridPaint; + IsShovelChecked = tool.Tool == EditorToolType.Shovel; + IsFlattenChecked = tool.Tool == EditorToolType.Flatten; + IsSmoothChecked = tool.Tool == EditorToolType.Smooth; + IsDragChecked = tool.Tool == EditorToolType.Drag; + IsRampChecked = tool.Tool == EditorToolType.Ramp; + IsQuarterPipeChecked = tool.Tool == EditorToolType.QuarterPipe; + IsHalfPipeChecked = tool.Tool == EditorToolType.HalfPipe; + IsBowlChecked = tool.Tool == EditorToolType.Bowl; + IsPyramidChecked = tool.Tool == EditorToolType.Pyramid; + IsTerrainChecked = tool.Tool == EditorToolType.Terrain; + IsPortalDiggerChecked = tool.Tool == EditorToolType.PortalDigger; + IsObjectEraserChecked = tool.Tool == EditorToolType.Eraser; + IsUVFixerChecked = tool.TextureUVFixer; + GridSize = tool.GridSize; + } + + private void UpdateBrushSettings() + { + IsBrushShapeCircleChecked = _editor.Configuration.ObjectBrush_Shape == ObjectBrushShape.Circle; + IsBrushShapeSquareChecked = _editor.Configuration.ObjectBrush_Shape == ObjectBrushShape.Square; + IsShowTexturesChecked = _editor.Configuration.ObjectBrush_ShowTextures; + } + + private void UpdateTextureState() + { + IsTextureEraserChecked = _editor.SelectedTexture.Texture is null; + IsTextureInvisibleChecked = _editor.SelectedTexture.Texture is TextureInvisible; + } + + private void UpdateModeVisibility() + { + CurrentMode = _editor.Mode; + } + + private void OnEditorEventRaised(IEditorEvent obj) + { + if (obj is Editor.ToolChangedEvent or Editor.InitEvent) + UpdateToolCheckedState(); + + if (obj is Editor.ObjectBrushSettingsChangedEvent or + Editor.ConfigurationChangedEvent or + Editor.InitEvent) + UpdateBrushSettings(); + + if (obj is Editor.SelectedTexturesChangedEvent or Editor.InitEvent) + UpdateTextureState(); + + if (obj is Editor.ModeChangedEvent or Editor.InitEvent) + UpdateModeVisibility(); + } + + #endregion State Updates +} diff --git a/TombEditor/Views/ContentBrowserView.xaml b/TombEditor/Views/ContentBrowserView.xaml new file mode 100644 index 0000000000..22a4ac24e4 --- /dev/null +++ b/TombEditor/Views/ContentBrowserView.xaml @@ -0,0 +1,432 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/TombEditor/Views/ContentBrowserView.xaml.cs b/TombEditor/Views/ContentBrowserView.xaml.cs new file mode 100644 index 0000000000..3885c3fec8 --- /dev/null +++ b/TombEditor/Views/ContentBrowserView.xaml.cs @@ -0,0 +1,659 @@ +#nullable enable + +using DarkUI.WPF.Extensions; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Media.Animation; +using TombEditor.ViewModels; + +namespace TombEditor.Views; + +public partial class ContentBrowserView : UserControl +{ + private Point _dragStartPoint; + private bool _isDragging; + private UIElement? _animatedElement; + private bool _suppressSelectionChanged; + + // Rubber-band selection state. + private bool _isRubberBanding; + + private Point _rubberBandOrigin; + + // Cached item bounds (built once at drag-start, avoids per-frame TransformToVisual). + private List<(AssetItemViewModel Item, Rect Bounds)>? _rubberBandItemBounds; + + // Tracks which items are currently inside the rubber-band rect (enables incremental diff). + private readonly HashSet _rubberBandSelected = new(); + + // Click-on-selected guard: prevents deselecting multi-selection on a plain click. + private bool _clickedOnSelected; + + private AssetItemViewModel? _clickedItem; + + public ContentBrowserView() + { + InitializeComponent(); + } + + /// + /// Handles selection changes in the ListBox. + /// Updates the ViewModel's SelectedItems collection for multi-selection support. + /// + private void AssetListBox_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (_suppressSelectionChanged) + return; + + if (DataContext is ContentBrowserViewModel vm) + { + var selectedItems = AssetListBox.SelectedItems.OfType().ToList(); + vm.UpdateSelectedItems(selectedItems); + } + } + + /// + /// Handles double-click on an asset to trigger the Add Item action, or loads a WAD when empty area is double-clicked. + /// Plays a subtle zoom+fade animation on the tile to confirm the action. + /// Uses PreviewMouseDoubleClick (tunneling) to catch events even in empty space. + /// Skipped when Ctrl is held (multi-selection mode). + /// + private void AssetListBox_PreviewMouseDoubleClick(object sender, MouseButtonEventArgs e) + { + if (e.ChangedButton != MouseButton.Left) + return; + + // Don't trigger placement when Ctrl is held (user is building a multi-selection). + if (Keyboard.Modifiers.HasFlag(ModifierKeys.Control)) + return; + + if (DataContext is not ContentBrowserViewModel vm) + return; + + var hitContainer = (e.OriginalSource as DependencyObject)?.FindVisualAncestorOrSelf(); + + if (hitContainer is not null && AssetListBox.SelectedItem is AssetItemViewModel) + { + // Double-click on an item: place it. + PlayAddItemAnimation(hitContainer); + vm.AddItemCommand.Execute(null); + e.Handled = true; + } + else if (hitContainer is null) + { + // Double-click on empty area: load a new WAD file. + vm.AddWadCommand.Execute(null); + e.Handled = true; + } + } + + /// + /// Plays a brief scale-up + fade-out animation on the given element + /// to provide visual feedback that an item is being placed. + /// The element stays grayed out until is called. + /// + private void PlayAddItemAnimation(UIElement element) + { + // Restore any previous animation before starting a new one. + RestoreLastAnimation(); + + _animatedElement = element; + + var duration = new Duration(TimeSpan.FromSeconds(0.25)); + + // Ensure a render transform exists. + if (element.RenderTransform is not ScaleTransform) + { + element.RenderTransform = new ScaleTransform(1, 1); + element.RenderTransformOrigin = new Point(0.5, 0.5); + } + + var scaleX = new DoubleAnimation(1.0, 1.08, duration) { EasingFunction = new QuadraticEase() }; + var scaleY = new DoubleAnimation(1.0, 1.08, duration) { EasingFunction = new QuadraticEase() }; + var fade = new DoubleAnimation(1.0, 0.4, duration) { EasingFunction = new QuadraticEase() }; + + element.RenderTransform.BeginAnimation(ScaleTransform.ScaleXProperty, scaleX); + element.RenderTransform.BeginAnimation(ScaleTransform.ScaleYProperty, scaleY); + element.BeginAnimation(OpacityProperty, fade); + } + + /// + /// Restores the last animated tile to its normal appearance by clearing + /// all running/held animations and resetting opacity and transform. + /// Called when EditorActionPlace finishes or is canceled. + /// + public void RestoreLastAnimation() + { + if (_animatedElement is null) + return; + + // Remove held animations so local values take effect again. + _animatedElement.BeginAnimation(OpacityProperty, null); + + if (_animatedElement.RenderTransform is ScaleTransform) + { + _animatedElement.RenderTransform.BeginAnimation(ScaleTransform.ScaleXProperty, null); + _animatedElement.RenderTransform.BeginAnimation(ScaleTransform.ScaleYProperty, null); + } + + _animatedElement.Opacity = 1.0; + _animatedElement.RenderTransform = new ScaleTransform(1, 1); + _animatedElement = null; + } + + /// + /// Records the mouse position when the left button is pressed for drag-drop detection. + /// When Alt is held, performs a "Locate Item" operation without changing selection. + /// When clicking on an already-selected item with multiple items selected and no modifiers, + /// suppresses deselection of the other items. + /// When clicking on empty space, initiates rubber-band selection. + /// + private void AssetListBox_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + // Don't start drag when clicking on the scrollbar. + if (IsOverScrollbar(e)) + return; + + _clickedOnSelected = false; + _clickedItem = null; + _isRubberBanding = false; + SelectionRect.Visibility = Visibility.Collapsed; + + // Alt+click: locate item without selecting it. + if (Keyboard.Modifiers.HasFlag(ModifierKeys.Alt)) + { + if (DataContext is ContentBrowserViewModel vm && e.OriginalSource is DependencyObject source) + { + var container = source.FindVisualAncestorOrSelf(); + + if (container?.DataContext is AssetItemViewModel item) + { + vm.RequestLocateItem(item); + e.Handled = true; + return; + } + } + } + + _dragStartPoint = e.GetPosition(null); + _isDragging = false; + + var hitContainer = (e.OriginalSource as DependencyObject)?.FindVisualAncestorOrSelf(); + + if (hitContainer?.DataContext is AssetItemViewModel hitItem && + AssetListBox.SelectedItems.Contains(hitItem) && + AssetListBox.SelectedItems.Count > 1 && + !Keyboard.Modifiers.HasFlag(ModifierKeys.Control) && + !Keyboard.Modifiers.HasFlag(ModifierKeys.Shift)) + { + // Prevent the ListBox from deselecting the multi-selection on mouse-down. + // If the user just clicks (no drag), we deselect in PreviewMouseLeftButtonUp. + _clickedOnSelected = true; + _clickedItem = hitItem; + e.Handled = true; + } + else if (hitContainer is null && !Keyboard.Modifiers.HasFlag(ModifierKeys.Alt)) + { + // Click on empty space → start rubber-band selection. + // Clear any stale visual flags from a previously-canceled drag. + foreach (var item in _rubberBandSelected) + item.IsRubberBandSelected = false; + + // Cache all item bounds now (one TransformToVisual call per item, not per frame). + _rubberBandOrigin = e.GetPosition(RubberBandCanvas); + _rubberBandSelected.Clear(); + _rubberBandItemBounds = new List<(AssetItemViewModel, Rect)>(); + + foreach (var assetItem in AssetListBox.Items.Cast()) + { + var c = AssetListBox.ItemContainerGenerator.ContainerFromItem(assetItem) as ListBoxItem; + + if (c is null) + continue; + + var origin = c.TransformToVisual(RubberBandCanvas).Transform(new Point(0, 0)); + _rubberBandItemBounds.Add((assetItem, new Rect(origin, new Size(c.ActualWidth, c.ActualHeight)))); + } + + _isRubberBanding = true; + e.Handled = true; + } + } + + /// + /// Finalizes the rubber-band selection when the left mouse button is released. + /// + private void AssetListBox_PreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e) + { + if (_isRubberBanding) + { + bool wasClicked = SelectionRect.Visibility == Visibility.Collapsed; + + // Clear IsRubberBandSelected visual flags from all in-progress items. + foreach (var item in _rubberBandSelected) + item.IsRubberBandSelected = false; + + _isRubberBanding = false; + SelectionRect.Visibility = Visibility.Collapsed; + _rubberBandItemBounds = null; + + if (DataContext is ContentBrowserViewModel vm) + { + if (wasClicked) + { + // Empty-space click with no drag: deselect all. + _rubberBandSelected.Clear(); + _suppressSelectionChanged = true; + AssetListBox.UnselectAll(); + _suppressSelectionChanged = false; + vm.UpdateSelectedItems(Array.Empty()); + } + else + { + // Commit the rubber-band set to the ListBox in one atomic batch. + // suppressing SelectionChanged so UpdateSelectedItems is called exactly once. + var selected = _rubberBandSelected.ToList(); + _rubberBandSelected.Clear(); + _suppressSelectionChanged = true; + AssetListBox.UnselectAll(); + + foreach (var item in selected) + AssetListBox.SelectedItems.Add(item); + + _suppressSelectionChanged = false; + vm.UpdateSelectedItems(selected); + } + } + else + { + _rubberBandSelected.Clear(); + } + + e.Handled = true; + return; + } + + // Multi-selection click guard: user clicked on a selected item without dragging. + // Now deselect the others and keep only the clicked item. + if (_clickedOnSelected && _clickedItem is not null) + { + _clickedOnSelected = false; + AssetListBox.SelectedItem = _clickedItem; + _clickedItem = null; + } + } + + /// + /// Cancels rubber-band selection when the mouse leaves the ListBox. + /// + private void AssetListBox_MouseLeave(object sender, MouseEventArgs e) + { + if (_isRubberBanding) + { + // Cancel rubber-band: clear visual flags and state. + foreach (var item in _rubberBandSelected) + item.IsRubberBandSelected = false; + + _isRubberBanding = false; + SelectionRect.Visibility = Visibility.Collapsed; + _rubberBandItemBounds = null; + _rubberBandSelected.Clear(); + } + } + + /// + /// Initiates a drag-drop operation by delegating to the WinForms host. + /// WPF's DragDrop.DoDragDrop uses COM OLE that doesn't interop cleanly + /// with WinForms drop targets. Instead, we raise DragDropRequested on the + /// ViewModel, and the WinForms ContentBrowser host calls Control.DoDragDrop. + /// + private void AssetListBox_PreviewMouseMove(object sender, MouseEventArgs e) + { + if (e.LeftButton != MouseButtonState.Pressed) + { + _isDragging = false; + return; + } + + // Update rubber-band rect and do an incremental selection diff against pre-cached bounds. + if (_isRubberBanding) + { + var cur = e.GetPosition(RubberBandCanvas); + var x = Math.Min(cur.X, _rubberBandOrigin.X); + var y = Math.Min(cur.Y, _rubberBandOrigin.Y); + var w = Math.Abs(cur.X - _rubberBandOrigin.X); + var h = Math.Abs(cur.Y - _rubberBandOrigin.Y); + + Canvas.SetLeft(SelectionRect, x); + Canvas.SetTop(SelectionRect, y); + SelectionRect.Width = Math.Max(w, 0); + SelectionRect.Height = Math.Max(h, 0); + SelectionRect.Visibility = Visibility.Visible; + + if ((w > 1 || h > 1) && _rubberBandItemBounds is not null) + { + var rubberRect = new Rect(x, y, w, h); + + // Update IsRubberBandSelected on each item for live visual feedback. + // Never modifies AssetListBox.SelectedItems during the drag - this avoids the SelectedItem binding side-effect and the ChosenItem/SyncSelectionFromEditor. + // feedback loop. The real selection is committed atomically in PreviewMouseLeftButtonUp. + + foreach (var (item, bounds) in _rubberBandItemBounds) + { + bool intersects = rubberRect.IntersectsWith(bounds); + bool wasSelected = _rubberBandSelected.Contains(item); + + if (intersects && !wasSelected) + { + _rubberBandSelected.Add(item); + item.IsRubberBandSelected = true; + } + else if (!intersects && wasSelected) + { + _rubberBandSelected.Remove(item); + item.IsRubberBandSelected = false; + } + } + } + + e.Handled = true; + return; + } + + // Don't drag when interacting with the scrollbar. + if (IsOverScrollbar(e)) + return; + + var currentPos = e.GetPosition(null); + var diff = _dragStartPoint - currentPos; + + // Check if the movement exceeds the system drag threshold. + if (Math.Abs(diff.X) < SystemParameters.MinimumHorizontalDragDistance && + Math.Abs(diff.Y) < SystemParameters.MinimumVerticalDragDistance) + return; + + if (_isDragging) + return; + + // Find the asset item under the cursor. + if (AssetListBox.SelectedItems.Count > 0 && + DataContext is ContentBrowserViewModel vm) + { + _isDragging = true; + + var selectedItems = AssetListBox.SelectedItems.Cast().ToList(); + vm.RequestDragDrop(selectedItems); // Delegate drag-drop to WinForms host via ViewModel event. + + _isDragging = false; + } + } + + // Handles keyboard navigation in the asset grid. Arrow keys move selection, Home/End jump to first/last item, and Enter confirms. + private void AssetListBox_KeyDown(object sender, KeyEventArgs e) + { + if (e.Key == Key.Escape) + { + // Escape clears the visual selection without touching ChosenItem/ChosenImportedGeometry. + + _suppressSelectionChanged = true; + AssetListBox.UnselectAll(); + _suppressSelectionChanged = false; + + if (DataContext is ContentBrowserViewModel vm) + vm.UpdateSelectedItems(Array.Empty()); + + e.Handled = true; + return; + } + + int itemCount = AssetListBox.Items.Count; + + if (itemCount == 0) + return; + + int currentIndex = AssetListBox.SelectedItem is AssetItemViewModel sel + ? AssetListBox.Items.IndexOf(sel) : -1; + + int newIndex; + + switch (e.Key) + { + case Key.Right: + newIndex = Math.Min(currentIndex + 1, itemCount - 1); + e.Handled = true; + break; + + case Key.Left: + newIndex = Math.Max(currentIndex - 1, 0); + e.Handled = true; + break; + + case Key.Down: + { + int columns = EstimateColumnsInRow(); + newIndex = Math.Min(currentIndex + columns, itemCount - 1); + e.Handled = true; + } + + break; + + case Key.Up: + { + int columns = EstimateColumnsInRow(); + newIndex = Math.Max(currentIndex - columns, 0); + e.Handled = true; + } + + break; + + case Key.Home: + newIndex = 0; + e.Handled = true; + break; + + case Key.End: + newIndex = itemCount - 1; + e.Handled = true; + break; + + case Key.PageDown: + { + int columns = EstimateColumnsInRow(); + int pageItems = columns * 4; + newIndex = Math.Min(currentIndex + pageItems, itemCount - 1); + e.Handled = true; + } + + break; + + case Key.PageUp: + { + int columns = EstimateColumnsInRow(); + int pageItems = columns * 4; + newIndex = Math.Max(currentIndex - pageItems, 0); + e.Handled = true; + } + + break; + + default: + return; + } + + if (newIndex >= 0 && newIndex < itemCount && newIndex != currentIndex) + { + AssetListBox.SelectedItem = AssetListBox.Items[newIndex]; + AssetListBox.ScrollIntoView(AssetListBox.SelectedItem); + } + } + + /// + /// Estimates how many tile columns fit in the current ListBox width. + /// + private int EstimateColumnsInRow() + { + double tileWidth = 92; // Default tile width + margin. + + if (DataContext is ContentBrowserViewModel vm) + tileWidth = vm.TileWidth + 4; // 2px margin on each side. + + double availableWidth = AssetListBox.ActualWidth - 20; // Account for scrollbar. + return Math.Max(1, (int)(availableWidth / tileWidth)); + } + + /// + /// When Ctrl or Alt is held, intercepts mouse wheel to zoom (change tile size) + /// instead of scrolling. + /// + private void AssetListBox_PreviewMouseWheel(object sender, MouseWheelEventArgs e) + { + if (Keyboard.Modifiers.HasFlag(ModifierKeys.Control) || Keyboard.Modifiers.HasFlag(ModifierKeys.Alt)) + { + if (DataContext is ContentBrowserViewModel vm) + { + const double step = 8; + double newWidth = vm.TileWidth + (e.Delta > 0 ? step : -step); + vm.TileWidth = Math.Clamp(newWidth, vm.MinTileWidth, vm.MaxTileWidth); + } + + e.Handled = true; + } + } + + /// + /// Returns true if the mouse event originated over a ScrollBar or its children + /// (Thumb, RepeatButton, Track, etc.), so drag-drop should not be initiated. + /// + private static bool IsOverScrollbar(MouseEventArgs e) + { + return e.OriginalSource is DependencyObject source + && (source is ScrollBar || source.FindVisualAncestor() is not null); + } + + /// + /// Programmatically sets the ListBox selection to a single item, + /// suppressing the SelectionChanged event to avoid feedback loops. + /// + public void SetSelectionSilently(AssetItemViewModel item) + { + _suppressSelectionChanged = true; + + AssetListBox.SelectedItems.Clear(); + + if (item is not null) + AssetListBox.SelectedItem = item; + + _suppressSelectionChanged = false; + } + + /// + /// Scrolls the ListBox to ensure the specified item is visible. + /// + public void ScrollToItem(AssetItemViewModel item) + { + if (item is null) + return; + + // ScrollIntoView doesn't work reliably with WrapPanel, so use BringIntoView on the container directly. + var container = AssetListBox.ItemContainerGenerator.ContainerFromItem(item) as FrameworkElement; + container?.BringIntoView(); + } + + private void ContentBrowser_DragEnter(object sender, DragEventArgs e) + { + if (e.Data.GetDataPresent(DataFormats.FileDrop)) + e.Effects = DragDropEffects.Copy; + else + e.Effects = DragDropEffects.None; + + e.Handled = true; + } + + private void ContentBrowser_Drop(object sender, DragEventArgs e) + { + if (e.Data.GetDataPresent(DataFormats.FileDrop)) + { + var files = e.Data.GetData(DataFormats.FileDrop) as string[]; + + if (files?.Length > 0 && DataContext is ContentBrowserViewModel vm) + vm.HandleFileDrop(files); + } + + e.Handled = true; + } + + private void FavoriteStar_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + if (sender is FrameworkElement element && + element.DataContext is AssetItemViewModel item && + DataContext is ContentBrowserViewModel vm) + { + vm.ToggleFavorite(item); + e.Handled = true; + } + } + + // Event raised when the viewport scrolls; host uses this to render visible thumbnails. + public event EventHandler? ViewportScrolled; + + private void AssetListBox_ScrollChanged(object sender, ScrollChangedEventArgs e) + { + ViewportScrolled?.Invoke(this, EventArgs.Empty); + } + + /// + /// Returns the list of AssetItemViewModels whose containers are currently visible in the viewport. + /// + public List GetVisibleItems() + { + var result = new List(); + var scrollViewer = FindVisualChild(AssetListBox); + + if (scrollViewer is null) + return result; + + var viewportRect = new Rect(0, 0, scrollViewer.ViewportWidth, scrollViewer.ViewportHeight); + + foreach (var item in AssetListBox.Items) + { + var container = AssetListBox.ItemContainerGenerator.ContainerFromItem(item) as FrameworkElement; + + if (container is null) + continue; + + var transform = container.TransformToAncestor(scrollViewer); + var itemRect = transform.TransformBounds(new Rect(0, 0, container.ActualWidth, container.ActualHeight)); + + if (viewportRect.IntersectsWith(itemRect) && item is AssetItemViewModel vm) + result.Add(vm); + } + + return result; + } + + private static T? FindVisualChild(DependencyObject parent) where T : DependencyObject + { + int count = VisualTreeHelper.GetChildrenCount(parent); + + for (int i = 0; i < count; i++) + { + var child = VisualTreeHelper.GetChild(parent, i); + + if (child is T result) + return result; + + var descendant = FindVisualChild(child); + + if (descendant is not null) + return descendant; + } + + return null; + } +} diff --git a/TombEditor/Views/ObjectBrushToolboxView.xaml b/TombEditor/Views/ObjectBrushToolboxView.xaml new file mode 100644 index 0000000000..22a9ac6bf5 --- /dev/null +++ b/TombEditor/Views/ObjectBrushToolboxView.xaml @@ -0,0 +1,150 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/TombEditor/Views/ObjectBrushToolboxView.xaml.cs b/TombEditor/Views/ObjectBrushToolboxView.xaml.cs new file mode 100644 index 0000000000..24a29cfbed --- /dev/null +++ b/TombEditor/Views/ObjectBrushToolboxView.xaml.cs @@ -0,0 +1,32 @@ +using System.ComponentModel; +using System.Windows.Controls; + +using TombEditor.ViewModels; + +namespace TombEditor.Views; + +public partial class ObjectBrushToolboxView : UserControl +{ + private ObjectBrushToolboxViewModel _viewModel; + + public ObjectBrushToolboxView() + { + InitializeComponent(); + + if (!DesignerProperties.GetIsInDesignMode(this)) + { + _viewModel = new ObjectBrushToolboxViewModel(); + DataContext = _viewModel; + Unloaded += (_, _) => _viewModel.Cleanup(); + } + } + + /// + /// Cleans up the ViewModel, unsubscribing from Editor events. + /// Safe to call multiple times. + /// + public void Cleanup() + { + _viewModel?.Cleanup(); + } +} diff --git a/TombEditor/Views/ToolBoxView.xaml b/TombEditor/Views/ToolBoxView.xaml new file mode 100644 index 0000000000..90225ca7af --- /dev/null +++ b/TombEditor/Views/ToolBoxView.xaml @@ -0,0 +1,317 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/TombEditor/Views/ToolBoxView.xaml.cs b/TombEditor/Views/ToolBoxView.xaml.cs new file mode 100644 index 0000000000..7cdbbb8394 --- /dev/null +++ b/TombEditor/Views/ToolBoxView.xaml.cs @@ -0,0 +1,200 @@ +using System; +using System.ComponentModel; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Threading; + +using TombEditor.ViewModels; + +namespace TombEditor.Views; + +public partial class ToolBoxView : UserControl +{ + private DispatcherTimer _contextMenuTimer; + + // Parent WinForms control reference for context menu hosting. + private System.Windows.Forms.Control _winFormsHost; + + // Reference to the ViewModel for cleanup purposes. + private ToolBoxViewModel _viewModel; + + // Fires when the preferred height of the visible content changes. + public event Action PreferredHeightChanged; + + // Fires when the preferred width of the visible content changes. + public event Action PreferredWidthChanged; + + public ToolBoxView() + { + InitializeComponent(); + Loaded += OnLoaded; + + if (!DesignerProperties.GetIsInDesignMode(this)) + { + _viewModel = new ToolBoxViewModel(); + DataContext = _viewModel; + + _viewModel.PropertyChanged += (_, e) => + { + if (e.PropertyName == nameof(ToolBoxViewModel.CurrentMode)) + RequestHeightUpdate(); + }; + + Unloaded += (_, _) => _viewModel.Cleanup(); + } + + IsVisibleChanged += (_, _) => RequestHeightUpdate(); + + _contextMenuTimer = new() + { + Interval = TimeSpan.FromMilliseconds(300) + }; + + _contextMenuTimer.Tick += OnContextMenuTimerTick; + } + + // Sets the parent WinForms control for context menu hosting. + public void SetWinFormsHost(System.Windows.Forms.Control host) + { + _winFormsHost = host; + } + + /// + /// Cleans up the ViewModel, unsubscribing from Editor events. + /// Safe to call multiple times. + /// + public void Cleanup() + { + _contextMenuTimer?.Stop(); + _viewModel?.Cleanup(); + } + + public Orientation PanelOrientation + { + get => toolPanel.Orientation; + set + { + if (toolPanel.Orientation == value) + return; + + toolPanel.Orientation = value; + UpdateSeparatorOrientation(); + RequestWidthUpdate(); + } + } + + private void UpdateSeparatorOrientation() + { + bool vertical = toolPanel.Orientation == Orientation.Vertical; + + foreach (var child in LogicalTreeHelper.GetChildren(toolPanel)) + { + if (child is Border border && border.Style == (Style)FindResource("ToolSeparator")) + { + if (vertical) + { + border.Width = double.NaN; + border.Height = 1; + } + else + { + border.Width = 1; + border.Height = double.NaN; + } + } + } + } + + #region Layout Measurement + + // Schedules a deferred preferred height recalculation. + public void RequestHeightUpdate() + { + Dispatcher.BeginInvoke(new Action(NotifyPreferredHeightChanged), DispatcherPriority.Render); + } + + // Schedules a deferred preferred width recalculation (vertical orientation only). + public void RequestWidthUpdate() + { + if (toolPanel.Orientation == Orientation.Vertical) + Dispatcher.BeginInvoke(new Action(NotifyPreferredWidthChanged), DispatcherPriority.Render); + } + + private void OnLoaded(object sender, RoutedEventArgs e) + { + RequestWidthUpdate(); + } + + private void NotifyPreferredWidthChanged() + { + if (Visibility != Visibility.Visible) + { + PreferredWidthChanged?.Invoke(0); + return; + } + + Measure(new Size(double.PositiveInfinity, double.PositiveInfinity)); + + double dpiScale = GetDpiScale(); + int width = Math.Max(1, (int)Math.Ceiling(DesiredSize.Width * dpiScale)); + + PreferredWidthChanged?.Invoke(width); + } + + private void NotifyPreferredHeightChanged() + { + if (Visibility != Visibility.Visible) + { + PreferredHeightChanged?.Invoke(0); + return; + } + + double availableWidth = ActualWidth > 0 ? ActualWidth : 9999; + Measure(new Size(availableWidth, double.PositiveInfinity)); + + double dpiScale = GetDpiScale(); + int height = Math.Max(1, (int)Math.Ceiling(DesiredSize.Height * dpiScale)); + + PreferredHeightChanged?.Invoke(height); + } + + private double GetDpiScale() + { + var source = PresentationSource.FromVisual(this); + return source?.CompositionTarget?.TransformToDevice.M22 ?? 1.0; + } + + #endregion Layout Measurement + + #region Grid Paint + + private void OnGridPaintMouseDown(object sender, MouseButtonEventArgs e) + { + if (e.RightButton == MouseButtonState.Pressed) + ShowGridPaintContextMenu(); + else + _contextMenuTimer.Start(); + } + + private void OnGridPaintMouseUp(object sender, MouseButtonEventArgs e) + { + _contextMenuTimer.Stop(); + } + + private void OnContextMenuTimerTick(object sender, EventArgs e) + { + _contextMenuTimer.Stop(); + ShowGridPaintContextMenu(); + } + + private void ShowGridPaintContextMenu() + { + var editor = Editor.Instance; + var owner = _winFormsHost as System.Windows.Forms.IWin32Window; + var menu = new Controls.ContextMenus.GridPaintContextMenu(editor, owner); + menu.Show(System.Windows.Forms.Cursor.Position); + } + + #endregion Grid Paint +} diff --git a/TombIDE/TombIDE.ProjectMaster/Services/EngineUpdate/TRXUpdateService.cs b/TombIDE/TombIDE.ProjectMaster/Services/EngineUpdate/TRXUpdateService.cs index 2be6b880a6..d99d7a7d81 100644 --- a/TombIDE/TombIDE.ProjectMaster/Services/EngineUpdate/TRXUpdateService.cs +++ b/TombIDE/TombIDE.ProjectMaster/Services/EngineUpdate/TRXUpdateService.cs @@ -17,7 +17,7 @@ namespace TombIDE.ProjectMaster.Services.EngineUpdate; /// public sealed class TRXUpdateService : IEngineUpdateService { - private static readonly Version MinAutoUpdateVersion = new(1, 2, 0); + private static readonly Version MinAutoUpdateVersion = new(1, 3, 1); private readonly IFileExtractionService _fileExtractionService; private readonly TRVersion.Game _gameVersion; @@ -46,7 +46,7 @@ public bool CanAutoUpdate(Version currentVersion, [NotNullWhen(false)] out strin { if (currentVersion < MinAutoUpdateVersion) { - blockReason = "Cannot Auto-Update engine. TRX 1.2 introduced breaking changes, which require manual migration."; + blockReason = "Cannot Auto-Update engine. TRX 1.3 introduced breaking changes, which require manual migration."; return false; } diff --git a/TombIDE/TombIDE.Shared/TIDE/TEN/API.xml b/TombIDE/TombIDE.Shared/TIDE/TEN/API.xml index 6b979f0b64..1f731bf773 100644 --- a/TombIDE/TombIDE.Shared/TIDE/TEN/API.xml +++ b/TombIDE/TombIDE.Shared/TIDE/TEN/API.xml @@ -1130,6 +1130,18 @@ custom menu creation, photo mode or time freeze. + + Flow + GetGlobalGameTime + Get global game session time. + Represents a global session time elapsed since the game launch. Does not correspond to time values in level or game statistics. + + + Time + Global game session time elapsed since the game launch. + + + Flow FlipMap @@ -1532,7 +1544,7 @@ custom menu creation, photo mode or time freeze. objectID Objects.ObjID - Object ID to use. Must be preset in the inventory. + Object ID to use. Must be present in the inventory. @@ -1558,7 +1570,7 @@ custom menu creation, photo mode or time freeze. objectID Objects.ObjID - Object ID of the item to select from inventory. Must be preset in the inventory. + Object ID of the item to select from inventory. Must be present in the inventory. @@ -1589,7 +1601,7 @@ custom menu creation, photo mode or time freeze. objectID Objects.ObjID - Object ID of the item to set. Must be preset in the inventory. + Object ID of the item to set. Must be present in the inventory. @@ -3742,7 +3754,7 @@ allocated even after timeout is reached, and can be shown again without re-initi ... ... A variable number of pairs of arguments, each pair consisting of:<br> - - a time in seconds (positive values are accepted and with only 1 tenth of a second [__0.1__]),<br> + - a time in seconds that can be rounded internally to the nearest game frame (1/30 of a second),<br> - followed by the function defined in the *LevelFuncs* table to call once the time has elapsed,<br> - followed by another duration in seconds, another function name, etc. @@ -3819,6 +3831,13 @@ allocated even after timeout is reached, and can be shown again without re-initi Begin or unpause a sequence. If showing the remaining time on-screen, its color will be set to white. + + EventSequence + EventSequence + Restart + Restart the sequence from its first timer. + If showing the remaining time on-screen, its color will be set to white. + EventSequence EventSequence @@ -3837,7 +3856,7 @@ allocated even after timeout is reached, and can be shown again without re-initi EventSequence EventSequence Stop - Stop the sequence. + Stop and reset the sequence to the first element. EventSequence @@ -3876,7 +3895,7 @@ allocated even after timeout is reached, and can be shown again without re-initi totalTime float - Duration of the timer, in seconds.<br>Values with only 1 tenth of a second (0.1) are accepted, example: 1.5 - 6.0 - 9.9 - 123.6. No negative values allowed! + Duration of the timer in seconds with 2 decimal places.<br>No negative values allowed. Values ​​are rounded to 2 decimal places and converted to 30 FPS game frames and rounded to the nearest frame. loop @@ -4029,7 +4048,7 @@ allocated even after timeout is reached, and can be shown again without re-initi float - The remaining time in seconds of timer.<br>Seconds have an accuracy of 0.1 tenths. Example: 1.5 - 6.0 - 9.9 - 123.6 + The remaining time in seconds of timer. Accuracy is 1 game frame (1/30 of a second). @@ -4043,7 +4062,7 @@ allocated even after timeout is reached, and can be shown again without re-initi timerFormat table|bool true - {minutes = true, seconds = true, deciseconds = true} + {minutes = true, seconds = true, centiseconds = true} Sets the remaining time display. See `timerFormat`.<br> @@ -4063,8 +4082,8 @@ allocated even after timeout is reached, and can be shown again without re-initi remainingTime float - The new time remaining for the timer in seconds.<br> - Values with only 1 tenth of a second (0.1) are accepted, example: 1.5 - 6.0 - 9.9 - 123.6. No negative values allowed! + The new time remaining for the timer in seconds with 2 decimal places<br> + No negative values allowed. Values ​​are rounded to 2 decimal places and converted to 30 FPS game frames and rounded to the nearest frame. @@ -4081,16 +4100,16 @@ allocated even after timeout is reached, and can be shown again without re-initi The type of comparison.<br> 0 : If the remaining time is equal to the value<br> 1 : If the remaining time is different from the value<br> - 2 : If the remaining time is less the value<br> + 2 : If the remaining time is less than the value<br> 3 : If the remaining time is less or equal to the value<br> - 4 : If the remaining time is greater the value<br> + 4 : If the remaining time is greater than the value<br> 5 : If the remaining time is greater or equal to the value seconds float The value in seconds to compare.<br> - Values with only 1 tenth of a second (0.1) are accepted, example: 1.5 - 6.0 - 9.9 - 123.6. No negative values allowed!<br> + No negative values allowed. Values are converted to 30 FPS game frames and rounded to the nearest frame.<br> Please note: to have continuous control, the remaining time must be controlled within the *OnLoop* event and only when the Timer is active @{Timer.IsActive}. @@ -4123,8 +4142,7 @@ allocated even after timeout is reached, and can be shown again without re-initi float - The timer's total time in seconds.<br> - Seconds have an accuracy of 0.1 tenths. Example: 1.5 - 6.0 - 9.9 - 123.6 + The timer's total time in seconds. Accuracy is 1 frame (1/30 second). @@ -4139,7 +4157,7 @@ allocated even after timeout is reached, and can be shown again without re-initi timerFormat table|bool true - {minutes = true, seconds = true, deciseconds = true} + {minutes = true, seconds = true, centiseconds = true} Sets the remaining time display. See `timerFormat`.<br> @@ -4159,8 +4177,8 @@ allocated even after timeout is reached, and can be shown again without re-initi totalTime float - Timer's new total time in seconds.<br> - Values with only 1 tenth of a second (0.1) are accepted, example: 1.5 - 6.0 - 9.9 - 123.6. No negative values allowed! + Timer's new total time in seconds with 2 decimal places.<br> + No negative values allowed. Values ​​are rounded to 2 decimal places and converted to 30 FPS game frames and rounded to the nearest frame. @@ -4186,7 +4204,7 @@ allocated even after timeout is reached, and can be shown again without re-initi seconds float the value in seconds to compare.<br> - Values with only 1 tenth of a second (0.1) are accepted, example: 1.5 - 6.0 - 9.9 - 123.6. No negative values allowed! + No negative values allowed. Values are converted to 30 FPS game frames and rounded to the nearest frame. @@ -4367,14 +4385,12 @@ allocated even after timeout is reached, and can be shown again without re-initi Timer Timer IsTicking - Checks if the timer has ticked (every 0.1 seconds). - Returns `true` every 0.1 seconds when the timer is active and not paused.<br> - TEN's engine runs on a 0.03-second internal tick, while this timer ticks every 0.1 seconds.<br> - Use `IsTicking()` to ensure consistency and avoid unexpected behavior — for example, inside the `OnLoop` event. + Checks if the timer has ticked. + Returns `true` only if the timer is ticking, i.e., it is active and not paused. - boolean - `true` if the timer ticked during this frame, `false` otherwise. + bool + `true` if the timer ticked, `false` otherwise. @@ -4614,7 +4630,7 @@ allocated even after timeout is reached, and can be shown again without re-initi bool true true - (optional) If `true`, an error message will be printed if the parameters are invalid. + (optional) If `true`, an error message will be printed in console if the parameters are invalid. @@ -9169,333 +9185,854 @@ No word wrapping will occur if this parameter is default or omitted. - View.DisplaySprite + View.DisplayItem tenclass - Represents a display sprite. + Represents a display item. + Display item is a 3D model of any object available in the level that can be drawn in 2D screen space. - DisplaySprite - Create a DisplaySprite object. + DisplayItem.SetAmbientLight + Set the ambient color for all display items. - objectID - Objects.ObjID - ID of the sprite sequence object. - - - index - int - Index of the sprite in the sequence. + color + Color + New ambient color for all display items. + + + + DisplayItem.SetCameraPosition + Set the camera location. + This single camera is used for all display items. + pos - Vec2 - Display position in percent. - - - rot - float - Rotation in degrees. - - - scale - Vec2 - Horizontal and vertical scale in percent. Scaling is interpreted by the DisplaySpriteEnum.ScaleMode passed to the Draw() function call. + Vec3 + New camera position. - color - Color + disableInterpolation + bool true - Color(255, 255, 255) - Color. + false + Disable interpolation to allow snap movements. - - - DisplaySprite - A new DisplaySprite object. - - - DisplaySprite - Create a DisplaySprite object with a video image. - Video should be played using @{View.PlayVideo} function in a background mode. If no video is played, sprite will not show. + DisplayItem.SetTargetPosition + Set the camera target location. pos - Vec2 - Display position in percent. + Vec3 + New target camera position. - rot - float - Rotation in degrees. + disableInterpolation + bool + true + false + Disable interpolation to allow snap movements. + + + + DisplayItem.SetFOV + Set the field of view for all display items. + - scale - Vec2 - Horizontal and vertical scale in percent. Scaling is interpreted by the DisplaySpriteEnum.ScaleMode passed to the Draw() function call. + fov + float + true + 80 + Field of view angle in degrees (clamped to [10, 170]). - color - Color + disableInterpolation + bool true - Color. __Default: Color(255, 255, 255, 255)__ + false + Disable interpolation to allow snap movements. - - - DisplaySprite - A new DisplaySprite object with attached video image. - - - DisplaySprite:GetObjectID - Get the object ID of the sprite sequence object used by the display sprite. + DisplayItem.GetAmbientLight + Get the ambient color of all the display items. - Objects.ObjID - Sprite sequence object ID. + Color + Ambient color. - DisplaySprite:GetSpriteID - Get the sprite ID in the sprite sequence object used by the display sprite. + DisplayItem.GetCameraPosition + Get the camera position. + This single camera is used for all display items. - int - Sprite ID in the sprite sequence object. Value __-1__ means that it is a background video, played using @{View.PlayVideo}. + Vec3 + Camera position for all display items. - DisplaySprite:GetPosition - Get the display position of the display sprite in percent. + DisplayItem.GetTargetPosition + Get the position of the camera target. - Vec2 - Display position in percent. + Vec3 + The camera target position for all of the display items. - DisplaySprite:GetRotation - Get the rotation of the display sprite in degrees. + DisplayItem.GetFOV + Get field of view angle for display items. float - Rotation in degrees. - - - - - DisplaySprite:GetScale - Get the horizontal and vertical scale of the display sprite in percent. - - - Vec2 - Horizontal and vertical scale in percent. + Current FOV angle in degrees. - DisplaySprite:GetColor - Get the color of the display sprite. - - - Color - Color. - - + DisplayItem.ResetCamera + Reset the camera position, camera target position, and field of view. - DisplaySprite:GetAnchors - Get the anchors of the display sprite. - Anchors are the vertices of the display sprite, which can be used to position other objects relative to it. + DisplayItem + Create a DisplayItem object. - alignMode - View.AlignMode + objectID + Objects.ObjID + Slot object ID. + + + pos + Vec3 true - DisplaySpriteAlignMode.Center - Alignment mode. + Vec3(0, 0, 0) + Position in 3D display space. - scaleMode - View.ScaleMode + rot + Rotation true - DisplaySpriteScaleMode.Fit - Scaling mode. + Rotation(0, 0, 0) + Rotation on the XYZ axes. + + + scale + Vec3 + true + Vec3(1, 1, 1) + Visual scale. + + + meshBits + int + true + Packed meshbits. - View.DisplayAnchors - An object containing the anchor points of the display sprite.<br> - The object contains the following fields:<br> - - `TOP_LEFT`<br> - - `TOP_CENTER`<br> - - `TOP_RIGHT`<br> - - `CENTER_LEFT`<br> - - `CENTER`<br> - - `CENTER_RIGHT`<br> - - `BOTTOM_RIGHT`<br> - - `BOTTOM_CENTER`<br> - - `BOTTOM_LEFT`<br> + DisplayItem + A new DisplayItem object. - DisplaySprite:SetObjectID - Set the sprite sequence object ID used by the display sprite. + DisplayItem:SetObjectID + Change the display item's object ID. objectID Objects.ObjID - New sprite sequence object ID. + New slot object ID. - DisplaySprite:SetSpriteID - Set the sprite ID in the sprite sequence object used by the display sprite. + DisplayItem:SetPosition + Set the display item's position. - spriteID - int - New sprite ID in the sprite sequence object. + pos + Vec3 + New position. - - - - DisplaySprite:SetPosition - Set the display position of the display sprite in percent. - - position - Vec2 - New display position in percent. + disableInterpolation + bool + true + false + Disable interpolation to allow snap movements. - DisplaySprite:SetRotation - Set the rotation of the display sprite in degrees. + DisplayItem:SetRotation + Set the display item's rotation. - rotation - float - New rotation in degrees. + rot + Rotation + New rotation. + + + disableInterpolation + bool + true + false + Disable interpolation to allow snap movements. - DisplaySprite:SetScale - Set the horizontal and vertical scale of the display sprite in percent. + DisplayItem:SetScale + Set the display item's scale. scale - Vec2 - New horizontal and vertical scale in percent. + Vec3 + New scale. + + + disableInterpolation + bool + true + false + Disable interpolation to allow snap movements. - DisplaySprite:SetColor - Set the color of the display sprite. + DisplayItem:SetColor + Set the display item's color. color Color New color. + + disableInterpolation + bool + true + false + Disable interpolation to allow snap color changes. + - DisplaySprite:Draw - Draw the display sprite in display space for the current frame. + DisplayItem:SetMeshBits + Set the packed mesh bits for the display item. + Mesh bits represent the visibility of every mesh in a given display item. Can be used in advanced workflows, such + as drawing a revolver with or without a lasersight. - priority + meshBits int - true - 0 - Draw priority. Can be thought of as a layer, with higher values having precedence. - Negative values will draw sprite above strings, while positive values will draw it under. - - - alignMode - View.AlignMode - true - View.AlignMode.CENTER - Align mode interpreting an offset from the sprite's position. + Packed MeshBits to be set. + + + + DisplayItem:SetMeshVisible + Make the specified mesh of a display item visible or invisible. + - scaleMode - View.ScaleMode - true - View.ScaleMode.FIT - Scale mode interpreting the display sprite's horizontal and vertical scale. + meshIndex + int + Mesh index. - blendMode - Effects.BlendID - true - Effects.BlendID.ALPHABLEND - Blend mode. + isVisible + bool + True to set visible, false to set invisible. - - - - Flow.AnimSettings + + DisplayItem:SetJointRotation + Set the display item's joint rotation. + + + meshIndex + int + Joint index.. + + + rot + Rotation + New rotation. + + + disableInterpolation + bool + true + false + Disables interpolation to allow for snap movements. + + + + + DisplayItem:SetAnim + Set the animation number of a display item. + + + animNumber + int + Animation number to set. + + + + + DisplayItem:SetFrame + Set the frame number of a display item's current animation. + This will set the specified animation to the given frame. + The number of frames in an animation can be seen under the heading "End frame" in the WadTool animation editor. + + + frameNumber + int + Frame number to set. + + + + + DisplayItem:GetObjectID + Retrieve the object ID from a display item. + + + Objects.ObjID + Slot object ID. + + + nil + If the display item does not exist. + + + + + DisplayItem:GetPosition + Get the display item's position. + + + Vec3 + Position. + + + nil + If the display item does not exist. + + + + + DisplayItem:GetRotation + Get the display item's rotation. + + + Rotation + Rotation. + + + nil + If the display item does not exist. + + + + + DisplayItem:GetScale + Get the display item's scale. + + + float + Scale. + + + nil + If the display item does not exist. + + + + + DisplayItem:GetColor + Get the display item's color. + + + Color + Color. + + + nil + If the display item does not exist. + + + + + DisplayItem:GetMeshVisible + Get the visibility state of a specified mesh in the display item. + + + index + int + Index of a mesh. + + + + + bool + Visibility status. + + + bool + False if the display item does not exist. + + + + + DisplayItem:GetJointRotation + Get the display item's joint rotation. + + + meshIndex + int + Index of the joint to check. + + + + + Rotation + Joint rotation. + + + nil + If the display item does not exist. + + + + + DisplayItem:GetAnim + Get the current animation number of a display item. + This corresponds to the number shown in the item's animation list in WadTool. + + + int + Active animation number. + + + nil + If the display item does not exist. + + + + + DisplayItem:GetFrame + Get the current frame number of the active animation of a display item. + + + int + Current frame number of the active animation. + + + nil + If the display item does not exist. + + + + + DisplayItem:GetEndFrame + Get the end frame number of the display item's active animation. + This is the "End Frame" set in WadTool for the animation.() + + + int + End frame number of the active animation. + + + nil + If the display item does not exist. + + + + + DisplayItem:GetBounds + Get the projected 2D bounding box of the display item. + Projects the display item into display space and returns two Vec2 values. + + + Vec2 + center Projected center position in display space in percent. + + + Vec2 + size The projected width/height in display space in percent. + + + nil + If the display item does not exist or has no bounds. + + + + + DisplayItem:Draw + Draw the display item in display space for the current frame. + + + + + View.DisplaySprite + tenclass + Represents a display sprite. + + + DisplaySprite + Create a DisplaySprite object. + + + objectID + Objects.ObjID + ID of the sprite sequence object. + + + index + int + Index of the sprite in the sequence. + + + pos + Vec2 + Display position in percent. + + + rot + float + Rotation in degrees. + + + scale + Vec2 + Horizontal and vertical scale in percent. Scaling is interpreted by the DisplaySpriteEnum.ScaleMode passed to the Draw() function call. + + + color + Color + true + Color(255, 255, 255) + Color. + + + + + DisplaySprite + A new DisplaySprite object. + + + + + DisplaySprite + Create a DisplaySprite object with a video image. + Video should be played using @{View.PlayVideo} function in a background mode. If no video is played, sprite will not show. + + + pos + Vec2 + Display position in percent. + + + rot + float + Rotation in degrees. + + + scale + Vec2 + Horizontal and vertical scale in percent. Scaling is interpreted by the DisplaySpriteEnum.ScaleMode passed to the Draw() function call. + + + color + Color + true + Color. __Default: Color(255, 255, 255, 255)__ + + + + + DisplaySprite + A new DisplaySprite object with attached video image. + + + + + DisplaySprite:GetObjectID + Get the object ID of the sprite sequence object used by the display sprite. + + + Objects.ObjID + Sprite sequence object ID. + + + + + DisplaySprite:GetSpriteID + Get the sprite ID in the sprite sequence object used by the display sprite. + + + int + Sprite ID in the sprite sequence object. Value __-1__ means that it is a background video, played using @{View.PlayVideo}. + + + + + DisplaySprite:GetPosition + Get the display position of the display sprite in percent. + + + Vec2 + Display position in percent. + + + + + DisplaySprite:GetRotation + Get the rotation of the display sprite in degrees. + + + float + Rotation in degrees. + + + + + DisplaySprite:GetScale + Get the horizontal and vertical scale of the display sprite in percent. + + + Vec2 + Horizontal and vertical scale in percent. + + + + + DisplaySprite:GetColor + Get the color of the display sprite. + + + Color + Color. + + + + + DisplaySprite:GetAnchors + Get the anchors of the display sprite. + Anchors are the vertices of the display sprite, which can be used to position other objects relative to it. + + + alignMode + View.AlignMode + true + DisplaySpriteAlignMode.Center + Alignment mode. + + + scaleMode + View.ScaleMode + true + DisplaySpriteScaleMode.Fit + Scaling mode. + + + + + View.DisplayAnchors + An object containing the anchor points of the display sprite.<br> + The object contains the following fields:<br> + - `TOP_LEFT`<br> + - `TOP_CENTER`<br> + - `TOP_RIGHT`<br> + - `CENTER_LEFT`<br> + - `CENTER`<br> + - `CENTER_RIGHT`<br> + - `BOTTOM_RIGHT`<br> + - `BOTTOM_CENTER`<br> + - `BOTTOM_LEFT`<br> + + + + + DisplaySprite:SetObjectID + Set the sprite sequence object ID used by the display sprite. + + + objectID + Objects.ObjID + New sprite sequence object ID. + + + + + DisplaySprite:SetSpriteID + Set the sprite ID in the sprite sequence object used by the display sprite. + + + spriteID + int + New sprite ID in the sprite sequence object. + + + + + DisplaySprite:SetPosition + Set the display position of the display sprite in percent. + + + position + Vec2 + New display position in percent. + + + + + DisplaySprite:SetRotation + Set the rotation of the display sprite in degrees. + + + rotation + float + New rotation in degrees. + + + + + DisplaySprite:SetScale + Set the horizontal and vertical scale of the display sprite in percent. + + + scale + Vec2 + New horizontal and vertical scale in percent. + + + + + DisplaySprite:SetColor + Set the color of the display sprite. + + + color + Color + New color. + + + + + DisplaySprite:Draw + Draw the display sprite in display space for the current frame. + + + priority + int + true + 0 + Draw priority. Can be thought of as a layer, with higher values having precedence. + Negative values will draw sprite above strings, while positive values will draw it under. + + + alignMode + View.AlignMode + true + View.AlignMode.CENTER + Align mode interpreting an offset from the sprite's position. + + + scaleMode + View.ScaleMode + true + View.ScaleMode.FIT + Scale mode interpreting the display sprite's horizontal and vertical scale. + + + blendMode + Effects.BlendID + true + Effects.BlendID.ALPHABLEND + Blend mode. + + + + + + + Flow.AnimSettings tenclass false These settings determine whether a specific moveset is available in-game. crawlExtended - (bool) Extended crawl moveset. + bool + Extended crawl moveset. When enabled, player will be able to traverse across one-click steps in crawlspaces. + true + true crouchRoll - (bool) Crouch roll. + bool + Crouch roll. When enabled, player can perform crawlspace roll by pressing sprint key. + true + true crawlspaceSwandive - (bool) Crawlspace dive. + bool + Crawlspace dive. When enabled, player will be able to swandive into crawlspaces. + true + true overhangClimb - (bool) Overhang climbing. - Enables overhang climbing feature. Currently does not work. + bool + Enables overhang climbing feature. Currently does not work. Overhang climbing. slideExtended - (bool) Extended slide mechanics. - If enabled, player will be able to change slide direction with controls. Currently does not work. + bool + If enabled, player will be able to change slide direction with controls. Currently does not work. Extended slide mechanics. sprintJump - (bool) Sprint jump. + bool + Sprint jump. If enabled, player will be able to perform extremely long jump when sprinting. + true + false ledgeJumps - (bool) Ledge jumps. + bool + Ledge jumps. If this setting is enabled, player will be able to jump upwards while hanging on the ledge. + true + false poseTimeout - (int) Pose timeout. + int + Pose timeout. If this setting is larger than 0, idle standing pose animation will be performed after given timeout (in seconds). + true + 20 @@ -9507,53 +10044,83 @@ No word wrapping will occur if this parameter is default or omitted. color - (Color) Flare color. + Color + Flare color. Flare color. Used for sparks and lensflare coloring as well. + true + TEN.Color(128, 64, 0) offset - (Vec3) Muzzle offset. + Vec3 + Muzzle offset. A relative muzzle offset where light and particle effects originate from. + true + Vec3(0, 0, 41) range - (int) Light range. + int + Light range. Flare light radius or range. Represented in "clicks" equal to 256 world units. + true + 9 timeout - (int) Burn timeout. + int + Burn timeout. Flare burn timeout. Flare will stop working after given timeout (specified in seconds). + true + 60 pickupCount - (int) Default flare pickup count. + int + Default flare pickup count. Specifies amount of flares that you get when you pick up a box of flares. + true + 12 lensflareBrightness - (float) Lens flare brightness. + float + Lens flare brightness. Brightness multiplier. Specifies how bright lens flare is in relation to light (on a range from 0 to 1). + true + 0.5 sparks - (bool) Toggle spark effect. + bool + Toggle spark effect. Spark effect. Determines whether flare generates sparks when burning. + true + true smoke - (bool) Toggle smoke effect. + bool + Toggle smoke effect. Smoke effect. Determines whether flare generates smoke when burning. + true + true muzzleGlow - (bool) Toggle muzzle glow effect. + bool + Toggle muzzle glow effect. Glow effect. Determines whether flare generates glow when burning. + true + false flicker - (bool) Toggle flicker effect. + bool + Toggle flicker effect. Light and lensflare flickering. When turned off, flare light will be constant. + true + true @@ -9565,17 +10132,22 @@ No word wrapping will occur if this parameter is default or omitted. rootMesh - (int) Root mesh to which hair object will attach to. + int + Root mesh to which hair object will attach to. Index of a root mesh to which hair will attach. Root mesh may be different for each hair object. + true + 14 offset - (Vec3) Relative braid offset to a headmesh. Not used with skinned hair mesh. + Vec3 + Relative braid offset to a headmesh. Not used with skinned hair mesh. Specifies how braid is positioned in relation to a headmesh. indices - (table) Braid connection indices. Not used with skinned hair mesh. + table + Braid connection indices. Not used with skinned hair mesh. A list of headmesh's vertex connection indices. Each index corresponds to nearest braid rootmesh vertex. Amount of indices is unlimited. @@ -9584,86 +10156,102 @@ No word wrapping will occur if this parameter is default or omitted.Flow.WeaponSettings tenclass false - This is a table of weapon settings, with several parameters available for every weapon. Access particular weapon's settings by using @{Objects.WeaponType} as an index for this table, e.g. `settings.Weapons[Flow.WeaponType.PISTOLS]`. + This is a table of weapon settings, with several parameters available for every weapon. Access particular weapon's settings by using @{Objects.WeaponType} as an index for this table, e.g. `settings.Weapons[Flow.WeaponType.PISTOLS]`. Default values for these settings are different for different weapons. Refer to *settings.lua* file to see them. accuracy - (float) Shooting accuracy. + float + Shooting accuracy. Determines accuracy range in angles (smaller angles mean higher accuracy). Applicable only for firearms. targetingDistance - (float) Targeting distance. + float + Targeting distance. Specifies maximum targeting distance in world units (1 block = 1024 world units) for a given weapon. interval - (float) Shooting interval. + float + Shooting interval. Specifies an interval (in frames), after which Lara is able to shoot again. Not applicable for backholster weapons. damage - (int) Damage. + int + Damage. Amount of hit points taken for every hit. alternateDamage - (int) Alternate damage. + int + Alternate damage. For crossbow, specifies damage for explosive ammo. waterLevel - (int) Water level. + int + Water level. Specifies water depth, at which Lara will put weapons back into holsters, indicating it's not possible to use it in water. pickupCount - (int) Default ammo pickup count. + int + Default ammo pickup count. Amount of ammo which is given with every ammo pickup for this weapon. flashColor - (Color) Gunflash color. + Color + Gunflash color. specifies the color of the gunflash. flashRange - (int) Gunflash range. + int + Gunflash range. specifies the range of the gunflash. flashDuration - (int) Gunflash duration. + int + Gunflash duration. specifies the duration of a gunflash effect. smoke - (bool) Gun smoke. + bool + Gun smoke. if set to true, indicates that weapon emits gun smoke. shell - (bool) Gun shell. + bool + Gun shell. If set to true, indicates that weapon emits gun shell. Applicable only for firearms. muzzleFlash - (bool) Display muzzle flash. + bool + Display muzzle flash. specifies whether muzzle flash should be displayed or not. muzzleGlow - (bool) Display muzzle glow. + bool + Display muzzle glow. specifies whether muzzle glow should be displayed or not. colorizeMuzzleFlash - (bool) Colorize muzzle flash. + bool + Colorize muzzle flash. specifies whether muzzle flash should be tinted with the same color as gunflash color. muzzleOffset - (Vec3) Muzzle offset. + Vec3 + Muzzle offset. specifies offset for spawning muzzle gunflash effects. @@ -9676,58 +10264,91 @@ No word wrapping will occur if this parameter is default or omitted. headerTextColor - (Color) Header text color. + Color + Header text color. A color used for displaying header text in system menus. + true + TEN.Color(216, 117, 49) optionTextColor - (Color) Option text color. + Color + Option text color. A color used for displaying option text in system menus. + true + TEN.Color(240, 220, 32) plainTextColor - (Color) Plain text color. + Color + Plain text color. A color used for displaying plain text in system menus. + true + TEN.Color(255, 255, 255) disabledTextColor - (Color) Disabled text color. + Color + Disabled text color. A color used for displaying any header text in menus. + true + TEN.Color(128, 128, 128) shadowTextColor - (Color) Shadow text color. + Color + Shadow text color. A color used for drawing a shadow under any rendered text. + true + TEN.Color(0, 0, 0) titleLogoPosition - (Vec2) Title logo center point position. + Vec2 + Title logo center point position. Center point of a title level logo position. + true + TEN.Vec2(50, 20) titleLogoScale - (Vec2) Title logo scale. + float + Title logo scale. Title level logo scale. + true + 0.38 titleLogoColor - (Color) Title logo color. + Color + Title logo color. Title level logo color. + true + TEN.Color(255, 255, 255) titleMenuPosition - (Vec2) Title menu position. - while vertical coordinate represents a first menu entry's vertical position. + Vec2 + Title menu position. + Title level menu position. Horizontal coordinate represents an alignment baseline, while vertical coordinate represents a first menu entry's vertical position. + true + TEN.Vec2(50, 66) titleMenuScale - (float) Title menu scale. + float + Title menu scale. Title level menu scale. + true + 1.0 titleMenuAlignment + Strings.DisplayStringOption Title menu alignment. - Can be set to @{Strings.DisplayStringOption.CENTER} or @{Strings.DisplayStringOption.RIGHT}. If set to `nil`, or set to any other value, menu will be aligned to the left side of the screen. + Specifies menu alignment. Can be set to @{Strings.DisplayStringOption.CENTER} or @{Strings.DisplayStringOption.RIGHT}. If set to `nil`, or set to any other value, menu will be aligned to the left side of the screen. + true + Strings.DisplayStringOption.CENTER @@ -9739,17 +10360,27 @@ No word wrapping will occur if this parameter is default or omitted. errorMode + Flow.ErrorMode How should the application respond to script errors? + Error mode to use. + true + Flow.ErrorMode.WARN multithreaded - (bool) Use multithreading in certain calculations. <br> - When set to `true`, some performance-critical calculations will be performed in parallel, which can give a significant performance boost. Don't disable unless you have problems with launching or using TombEngine. + bool + Use multithreading in certain calculations. <br> + Determines whether to use multithreading or not. When set to `true`, some performance-critical calculations will be performed in parallel, which can give a significant performance boost. Don't disable unless you have problems with launching or using TombEngine. + true + true fastReload - (bool) Can the game utilize the fast reload feature? <br> - When set to `true`, the game will attempt to perform fast savegame reloading if current level is the same as the level loaded from the savegame. It will not work if the level timestamp or checksum has changed (i.e. level was updated). If set to `false`, this functionality is turned off. + bool + Can the game utilize the fast reload feature? <br> + Toggles fast reload on or off. When set to `true`, the game will attempt to perform fast savegame reloading if current level is the same as the level loaded from the savegame. It will not work if the level timestamp or checksum has changed (i.e. level was updated). If set to `false`, this functionality is turned off. + true + true @@ -9761,13 +10392,19 @@ No word wrapping will occur if this parameter is default or omitted. ambientOcclusion - (bool) Enable ambient occlusion. + bool + Enable ambient occlusion. If disabled, ambient occlusion setting will be forced to off, and corresponding menu entry in the Display Settings dialog will be grayed out. + true + true skinning - (bool) Enable skinning. + bool + Enable skinning. If enabled, skinning will be used for animated objects with skinned mesh. Disable to force classic TR workflow. + true + true @@ -9779,13 +10416,19 @@ No word wrapping will occur if this parameter is default or omitted. gravity - (float) Global world gravity. + float + Global world gravity. Specifies global gravity. Mostly affects Lara and several other objects. + true + 6.0 swimVelocity - (float) Swim velocity. + float + Swim velocity. Specifies swim velocity for Lara. Affects both surface and underwater. + true + 50.0 @@ -9797,23 +10440,35 @@ No word wrapping will occur if this parameter is default or omitted. statusBars - (bool) Toggle in-game status bars visibility. + bool + Toggle in-game status bars visibility. If disabled, all status bars (health, air, stamina) will be hidden. + true + true loadingBar - (bool) Toggle loading bar visibility. + bool + Toggle loading bar visibility. If disabled, loading bar will be invisible in game. + true + true speedometer - (bool) Toggle speedometer visibility. + bool + Toggle speedometer visibility. If disabled, speedometer will be invisible in game. + true + true pickupNotifier - (bool) Toggle pickup notifier visibility. + bool + Toggle pickup notifier visibility. If disabled, pickup notifier will be invisible in game. + true + true @@ -9825,18 +10480,27 @@ No word wrapping will occur if this parameter is default or omitted. binocularLightColor - (Color) Determines highlight color in binocular mode. + Color + Determines highlight color in binocular mode. Color of highlight, when player presses action. Zero color means there will be no highlight. + true + TEN.Color(192, 192, 96) lasersightLightColor - (Color) Determines highlight color in lasersight mode. + Color + Determines highlight color in lasersight mode. Lasersight highlight color. Zero color means there will be no highlight. + true + TEN.Color(255, 0, 0) objectCollision - (bool) Specify whether camera can collide with objects. + bool + Specify whether camera can collide with objects. When enabled, camera will collide with moveables and statics. Disable for TR4-like camera behaviour. + true + true @@ -9848,62 +10512,99 @@ No word wrapping will occur if this parameter is default or omitted. mode + Flow.PathfindingMode Pathfinding mode. + The algorithm used for pathfinding. For more information, refer to @{Flow.PathfindingMode}. + true + Flow.PathfindingMode.ASTAR searchDepth - (int) Pathfinding graph search depth. + int + Pathfinding graph search depth. Specifies how deep the AI will search the pathfinding graph when calculating a path to the target. + true + 5 escapeDistance - (int) Escape distance. - value specifies the distance the enemy will try to reach when escaping. + int + Escape distance. + If enemy is being attacked, it attempts to escape as far as possible from the attacker. This value specifies the distance the enemy will try to reach when escaping. + true + 5 stalkDistance - (int) Stalk distance. - previously escaped. + int + Stalk distance. + Distance at which an enemy may start to track a target without attempting to attack. + true + 3 predictionFactor - (float) Path prediction scale factor. - current velocity. A higher value makes enemies intercept the target earlier, while a lower value reduces anticipation. If set to 0, prediction will be disabled. + float + Path prediction scale factor. + Determines how far ahead enemy predicts the target's position based on its current velocity. A higher value makes enemies intercept the target earlier, while a lower value reduces anticipation. If set to 0, prediction will be disabled. + true + 15.0 collisionPenaltyThreshold - (float) Collision penalty threshold. - collisions with illegal geometry and will be forced to ignore its current path to the target and recalculate it. If set to 0, collision penalties will be disabled. + float + Collision penalty threshold. + Specifies the timeout in seconds after which the enemy will be punished for collisions with illegal geometry and will be forced to ignore its current path to the target and recalculate it. If set to 0, collision penalties will be disabled. + true + 1.0 collisionPenaltyCooldown - (float) Collision penalty cooldown. - in seconds during which the enemy will ignore the path to the target which previously caused a penalty. + float + Collision penalty cooldown. + If a collision penalty was applied to an enemy, this value specifies the timeout in seconds during which the enemy will ignore the path to the target which previously caused a penalty. + true + 6.0 moveableAvoidance - (bool) Moveable avoidance. - moveable if it's in the way. Applies only to moveables not placed near room geometry. + bool + Moveable avoidance. + Avoid collisions with moveables where possible. Enemy will attempt to turn away from the moveable if it's in the way. Applies only to moveables not placed near room geometry. Experimental feature, use with caution. + true + false staticMeshAvoidance - (bool) Static mesh avoidance. - static mesh if it's in the way. Applies only to static meshes not placed near room geometry. + bool + Static mesh avoidance. + Avoid collisions with static meshes where possible. Enemy will attempt to turn away from the static mesh if it's in the way. Applies only to static meshes not placed near room geometry. Experimental feature, use with caution. + true + false verticalGeometryAvoidance - (bool) Vertical geometry avoidance for swimming and flying enemies. - by moving upwards. + bool + Vertical geometry avoidance for swimming and flying enemies. + Avoid swimming or flying forward into illegal room geometry that can be avoided by moving upwards. + true + true waterSurfaceAvoidance - (bool) Water surface avoidance for swimming and flying enemies. - the player or other enemies from above. For swimming enemies, adds extra measures to avoid glitching out of the water. + bool + Water surface avoidance for swimming and flying enemies. + For flying enemies, prevents diving into the water and dying while attacking the player or other enemies from above. For swimming enemies, adds extra measures to avoid glitching out of the water. + true + true verticalMovementSmoothing - (bool) Vertical movement smoothing for swimming and flying enemies. - sudden unnatural jerks or changes in direction. + bool + Vertical movement smoothing for swimming and flying enemies. + Smooths out vertical movement for swimming and flying enemies to prevent sudden unnatural jerks or changes in direction. + true + true @@ -9915,25 +10616,34 @@ No word wrapping will occur if this parameter is default or omitted. enableInventory - (bool) Enable or disable original linear inventory functionality. Can be used to completely disable inventory handling - or to replace it with custom module, such as ring inventory. + bool + Enable or disable original linear inventory functionality. Can be used to completely disable inventory handling + If false, inventory will not open. or to replace it with custom module, such as ring inventory. + true + true killPoisonedEnemies - (bool) Kill enemies which were poisoned by a crossbow poisoned ammo or by any other means. If disabled, enemy hit points will - reach minimum but will never go to zero. This behaviour replicates original TR4 behaviour. + bool + Kill enemies which were poisoned by a crossbow poisoned ammo or by any other means. If disabled, enemy hit points will + If false, enemies won't be killed by poison. reach minimum but will never go to zero. This behaviour replicates original TR4 behaviour. + true + true targetObjectOcclusion - (bool) Enable target occlusion by moveables and static meshes. + bool + Enable target occlusion by moveables and static meshes. If enabled, player won't be able to target enemies through moveables and static meshes. + true + true Flow.Settings tenclass - Global engine settings which don't fall into particular category or can't be assigned to a specific object. Can be accessed using @{Flow.SetSettings} and @{Flow.GetSettings} functions. + Global engine settings which don't fall into particular category or can't be assigned to a specific object. Flow.Settings is composed of several sub-tables, and each section of the Flow.Settings documentation corresponds to one of these sub-tables. These configuration groups are located in *settings.lua* script file. It is possible to change settings on a per-level basis via @{Flow.GetSettings} and @{Flow.SetSettings} functions, but keep in mind that _settings.lua is reread every time the level is reloaded_. Therefore, you need to implement custom settings management in your level script if you want to override global settings. Animations @@ -10004,8 +10714,8 @@ No word wrapping will occur if this parameter is default or omitted. Weapons { [WeaponType]: WeaponSettings } - This is a table of weapon settings, with several parameters available for every weapon. Access particular weapon's settings by using @{Objects.WeaponType} as an index for this table, e.g. `settings.Weapons[Flow.WeaponType.PISTOLS]`. - This is a table of weapon settings, with several parameters available for every weapon. Access particular weapon's settings by using @{Objects.WeaponType} as an index for this table, e.g. `settings.Weapons[Flow.WeaponType.PISTOLS]`. + This is a table of weapon settings, with several parameters available for every weapon. Access particular weapon's settings by using @{Objects.WeaponType} as an index for this table, e.g. `settings.Weapons[Flow.WeaponType.PISTOLS]`. Default values for these settings are different for different weapons. Refer to *settings.lua* file to see them. + This is a table of weapon settings, with several parameters available for every weapon. Access particular weapon's settings by using @{Objects.WeaponType} as an index for this table, e.g. `settings.Weapons[Flow.WeaponType.PISTOLS]`. Default values for these settings are different for different weapons. Refer to *settings.lua* file to see them. diff --git a/TombIDE/TombIDE.Shared/TIDE/Templates/Extras/FLEP.zip b/TombIDE/TombIDE.Shared/TIDE/Templates/Extras/FLEP.zip index 88d18e86ac..585479a359 100644 Binary files a/TombIDE/TombIDE.Shared/TIDE/Templates/Extras/FLEP.zip and b/TombIDE/TombIDE.Shared/TIDE/Templates/Extras/FLEP.zip differ diff --git a/TombIDE/TombIDE.Shared/TIDE/Templates/Presets/TEN.zip b/TombIDE/TombIDE.Shared/TIDE/Templates/Presets/TEN.zip index 0ac5b3c9f7..8083e74430 100644 Binary files a/TombIDE/TombIDE.Shared/TIDE/Templates/Presets/TEN.zip and b/TombIDE/TombIDE.Shared/TIDE/Templates/Presets/TEN.zip differ diff --git a/TombIDE/TombIDE.Shared/TIDE/Templates/Presets/TR1.zip b/TombIDE/TombIDE.Shared/TIDE/Templates/Presets/TR1.zip index 4813303084..eba4b153e9 100644 Binary files a/TombIDE/TombIDE.Shared/TIDE/Templates/Presets/TR1.zip and b/TombIDE/TombIDE.Shared/TIDE/Templates/Presets/TR1.zip differ diff --git a/TombIDE/TombIDE.Shared/TIDE/Templates/Presets/TR2X.zip b/TombIDE/TombIDE.Shared/TIDE/Templates/Presets/TR2X.zip index cde330f228..9c2edb104d 100644 Binary files a/TombIDE/TombIDE.Shared/TIDE/Templates/Presets/TR2X.zip and b/TombIDE/TombIDE.Shared/TIDE/Templates/Presets/TR2X.zip differ diff --git a/TombIDE/TombIDE/Properties/DesignTimeResources.xaml b/TombIDE/TombIDE/Properties/DesignTimeResources.xaml new file mode 100644 index 0000000000..1e614024b1 --- /dev/null +++ b/TombIDE/TombIDE/Properties/DesignTimeResources.xaml @@ -0,0 +1,6 @@ + + + + + + diff --git a/TombIDE/TombIDE/TombIDE.csproj b/TombIDE/TombIDE/TombIDE.csproj index 268ae8f826..8b5c13a49b 100644 --- a/TombIDE/TombIDE/TombIDE.csproj +++ b/TombIDE/TombIDE/TombIDE.csproj @@ -1,4 +1,11 @@  + + + MSBuild:Compile + Designer + true + + net6.0-windows WinExe diff --git a/TombLib/TombLib.Forms/Controls/OffscreenItemRenderer.cs b/TombLib/TombLib.Forms/Controls/OffscreenItemRenderer.cs new file mode 100644 index 0000000000..126c080f57 --- /dev/null +++ b/TombLib/TombLib.Forms/Controls/OffscreenItemRenderer.cs @@ -0,0 +1,199 @@ +using NLog; +using SharpDX.Direct3D11; +using SharpDX.DXGI; +using SharpDX.Toolkit.Graphics; +using System; +using System.Numerics; +using System.Runtime.InteropServices; +using TombLib.Graphics; +using TombLib.LevelData; +using TombLib.Rendering.DirectX11; +using TombLib.Utils; +using TombLib.Wad; +using Texture2D = SharpDX.Direct3D11.Texture2D; + +namespace TombLib.Controls +{ + public class OffscreenItemRenderer : IDisposable + { + private static readonly Logger logger = LogManager.GetCurrentClassLogger(); + + private readonly Dx11RenderingDevice _device; + private readonly GraphicsDevice _legacyDevice; + private readonly WadRenderer _wadRenderer; + + private Texture2D _renderTarget; + private RenderTargetView _renderTargetView; + private Texture2D _depthBuffer; + private DepthStencilView _depthBufferView; + private Texture2D _stagingTexture; + private int _currentSize; + + public OffscreenItemRenderer() + { + _device = (Dx11RenderingDevice)DeviceManager.DefaultDeviceManager.Device; + _legacyDevice = DeviceManager.DefaultDeviceManager.___LegacyDevice; + _wadRenderer = new WadRenderer(_legacyDevice, true, true, 1024, 512, false); + } + + public ImageC RenderThumbnail(IWadObject wadObject, TRVersion.Game version, Vector4 backColor, int size = 128) + { + const int FieldOfView = 50; + + if (wadObject == null) + return ImageC.CreateNew(size, size); + + try + { + EnsureRenderTarget(size); + + // Set up camera using shared helper. + var camera = WadObjectRenderHelper.CreateCameraForObject(wadObject, _wadRenderer, FieldOfView); + if (camera == null) + return ImageC.CreateNew(size, size); + + // Bind our offscreen render target. + BindRenderTarget(size); + + // Clear + _device.Context.ClearRenderTargetView(_renderTargetView, new SharpDX.Color4(backColor.X, backColor.Y, backColor.Z, backColor.W)); + _device.Context.ClearDepthStencilView(_depthBufferView, DepthStencilClearFlags.Depth, 1.0f, 0); + + // Reset device state. + _device.ResetState(); + + // Get view-projection matrix. + var viewProjection = camera.GetViewProjectionMatrix(size, size); + + // Render the object using shared helper. + WadObjectRenderHelper.RenderObject(wadObject, _wadRenderer, _legacyDevice, viewProjection, camera.GetPosition(), false); + + // Read back pixels. + return ReadPixels(size); + } + catch + { + logger.Error("Error while rendering thumbnail for object " + wadObject.ToString(version)); + return Dx11RenderingDevice.TextureUnavailable; + } + } + + private void EnsureRenderTarget(int size) + { + if (_renderTarget != null && _currentSize == size) + return; + + DisposeRenderTargets(); + _currentSize = size; + + // Create color render target. + _renderTarget = new Texture2D(_device.Device, new Texture2DDescription + { + Format = Format.B8G8R8A8_UNorm, + Width = size, + Height = size, + ArraySize = 1, + MipLevels = 1, + SampleDescription = new SampleDescription(1, 0), + Usage = ResourceUsage.Default, + BindFlags = BindFlags.RenderTarget, + CpuAccessFlags = CpuAccessFlags.None, + OptionFlags = ResourceOptionFlags.None + }); + _renderTargetView = new RenderTargetView(_device.Device, _renderTarget); + + // Create depth buffer. + _depthBuffer = new Texture2D(_device.Device, new Texture2DDescription + { + Format = Format.D32_Float, + Width = size, + Height = size, + ArraySize = 1, + MipLevels = 1, + SampleDescription = new SampleDescription(1, 0), + Usage = ResourceUsage.Default, + BindFlags = BindFlags.DepthStencil, + CpuAccessFlags = CpuAccessFlags.None, + OptionFlags = ResourceOptionFlags.None + }); + _depthBufferView = new DepthStencilView(_device.Device, _depthBuffer); + + // Create staging texture for CPU readback. + _stagingTexture = new Texture2D(_device.Device, new Texture2DDescription + { + Format = Format.B8G8R8A8_UNorm, + Width = size, + Height = size, + ArraySize = 1, + MipLevels = 1, + SampleDescription = new SampleDescription(1, 0), + Usage = ResourceUsage.Staging, + BindFlags = BindFlags.None, + CpuAccessFlags = CpuAccessFlags.Read, + OptionFlags = ResourceOptionFlags.None + }); + } + + private void BindRenderTarget(int size) + { + _device.Context.Rasterizer.SetViewport(0, 0, size, size, 0.0f, 1.0f); + _device.Context.OutputMerger.SetTargets(_depthBufferView, _renderTargetView); + _device.CurrentRenderTarget = null; + } + + private ImageC ReadPixels(int size) + { + // Copy render target to staging texture. + _device.Context.CopyResource(_renderTarget, _stagingTexture); + + // Map and read pixels. + var dataBox = _device.Context.MapSubresource(_stagingTexture, 0, MapMode.Read, SharpDX.Direct3D11.MapFlags.None); + try + { + int bytesPerPixel = 4; + int rowPitch = dataBox.RowPitch; + byte[] pixels = new byte[size * size * bytesPerPixel]; + + // Copy row by row (rowPitch may differ from size * bytesPerPixel due to alignment). + for (int y = 0; y < size; y++) + Marshal.Copy(dataBox.DataPointer + y * rowPitch, pixels, y * size * bytesPerPixel, size * bytesPerPixel); + + return ImageC.FromByteArray(pixels, size, size); + } + catch + { + logger.Error("Error while reading pixels from offscreen render target."); + return Dx11RenderingDevice.TextureUnavailable; + } + finally + { + _device.Context.UnmapSubresource(_stagingTexture, 0); + } + } + + private void DisposeRenderTargets() + { + _stagingTexture?.Dispose(); + _stagingTexture = null; + _depthBufferView?.Dispose(); + _depthBufferView = null; + _depthBuffer?.Dispose(); + _depthBuffer = null; + _renderTargetView?.Dispose(); + _renderTargetView = null; + _renderTarget?.Dispose(); + _renderTarget = null; + } + + public void GarbageCollect() + { + _wadRenderer?.GarbageCollect(); + } + + public void Dispose() + { + DisposeRenderTargets(); + _wadRenderer?.Dispose(); + } + } +} diff --git a/TombLib/TombLib.Forms/Controls/PanelItemPreview.cs b/TombLib/TombLib.Forms/Controls/PanelItemPreview.cs index cd66a92aaa..b48c207253 100644 --- a/TombLib/TombLib.Forms/Controls/PanelItemPreview.cs +++ b/TombLib/TombLib.Forms/Controls/PanelItemPreview.cs @@ -158,52 +158,10 @@ public override void InitializeRendering(RenderingDevice device, bool antialias public void ResetCamera() { - if (CurrentObject == null) - Camera = new ArcBallCamera(new Vector3(0.0f, 256.0f, 0.0f), 0, 0, -(float)Math.PI / 2, (float)Math.PI / 2, 2048.0f, 100, 1000000, FieldOfView * (float)(Math.PI / 180)); - else - { - var bs = new BoundingSphere(new Vector3(0.0f, 256.0f, 0.0f), 640.0f); - var center = Vector3.Zero; - var radius = 256.0f; - - if (CurrentObject is WadMoveable) - { - AnimatedModel model = _wadRenderer.GetMoveable((WadMoveable)CurrentObject); - - if (model.Animations.Count > 0 && model.Animations[0].KeyFrames.Count > 0) - { - // Use first animation and first frame as preview - model.UpdateAnimation(0, 0); - var bb = model.Animations[0].KeyFrames[0].CalculateBoundingBox(model, model); - bs = BoundingSphere.FromBoundingBox(bb); - } - } - - if (CurrentObject is WadStatic) - { - var st = CurrentObject as WadStatic; - if (st.Mesh != null) - bs = (CurrentObject as WadStatic).Mesh.CalculateBoundingSphere(); - } - - if (CurrentObject is ImportedGeometry) - { - var impgeo = (CurrentObject as ImportedGeometry); - - if (impgeo.DirectXModel != null && impgeo.DirectXModel.Meshes != null) - { - var bb = new BoundingBox(); - foreach (var mesh in impgeo.DirectXModel.Meshes) - bb = bb.Union(mesh.BoundingBox); - bs = BoundingSphere.FromBoundingBox(bb); - } - } - - center = bs.Center; - radius = bs.Radius * 1.15f; // Zoom out a bit + Func defaultCamera = () => + new ArcBallCamera(new Vector3(0.0f, 256.0f, 0.0f), 0, 0, -(float)Math.PI / 2, (float)Math.PI / 2, 2048.0f, 100, 1000000, FieldOfView * (float)(Math.PI / 180)); - Camera = new ArcBallCamera(center, MathC.DegToRad(35), MathC.DegToRad(35), -(float)Math.PI / 2, (float)Math.PI / 2, radius * 3, 50, 1000000, FieldOfView * (float)(Math.PI / 180)); - } + Camera = WadObjectRenderHelper.CreateCameraForObject(CurrentObject, _wadRenderer, FieldOfView) ?? defaultCamera(); } public void GarbageCollect() @@ -265,107 +223,8 @@ protected override void OnDraw() ((Rendering.DirectX11.Dx11RenderingDevice)Device).ResetState(); Matrix4x4 viewProjection = Camera.GetViewProjectionMatrix(Width, Height); - if (CurrentObject is WadMoveable) - { - // HACK: new moveables have one bone with null mesh - var moveable = (WadMoveable)CurrentObject; - if (moveable.Meshes.Count == 0 || (moveable.Meshes.Count == 1 && moveable.Meshes[0] == null)) - return; - - AnimatedModel model = _wadRenderer.GetMoveable((WadMoveable)CurrentObject); - - // We don't need to rebuilt it everytime necessarily, but it's cheap to so and - // simpler than trying to figure out when it may be necessary. - model.UpdateAnimation(0, 0); - - var effect = DeviceManager.DefaultDeviceManager.___LegacyEffects["Model"]; - - effect.Parameters["AlphaTest"].SetValue(DrawTransparency); - effect.Parameters["Color"].SetValue(Vector4.One); - effect.Parameters["StaticLighting"].SetValue(false); - effect.Parameters["ColoredVertices"].SetValue(false); - effect.Parameters["Texture"].SetResource(_wadRenderer.Texture); - effect.Parameters["TextureSampler"].SetResource(_legacyDevice.SamplerStates.Default); - - // Build animation transforms - var matrices = new List(); - if (model.Animations.Count != 0) - { - for (var b = 0; b < model.Meshes.Count; b++) - matrices.Add(model.AnimationTransforms[b]); - } - else - { - foreach (var bone in model.Bones) - matrices.Add(bone.GlobalTransform); - } - if (model.Skin != null) - model.RenderSkin(_legacyDevice, effect, viewProjection.ToSharpDX()); - - for (int i = 0; i < model.Meshes.Count; i++) - { - var mesh = model.Meshes[i]; - if (mesh.Vertices.Count == 0) - continue; - - if (model.Skin != null && mesh.Hidden) - continue; - - mesh.UpdateBuffers(Camera.GetPosition()); - - _legacyDevice.SetVertexBuffer(0, mesh.VertexBuffer); - _legacyDevice.SetIndexBuffer(mesh.IndexBuffer, true); - _legacyDevice.SetVertexInputLayout(mesh.InputLayout); - - effect.Parameters["ModelViewProjection"].SetValue((matrices[i] * viewProjection).ToSharpDX()); - - effect.Techniques[0].Passes[0].Apply(); - - foreach (var submesh in mesh.Submeshes) - { - submesh.Value.Material.SetStates(_legacyDevice, DrawTransparency); - _legacyDevice.Draw(PrimitiveType.TriangleList, submesh.Value.NumIndices, submesh.Value.BaseIndex); - } - } - } - else if (CurrentObject is WadStatic) - { - StaticModel model = _wadRenderer.GetStatic((WadStatic)CurrentObject); - - var effect = DeviceManager.DefaultDeviceManager.___LegacyEffects["Model"]; - - effect.Parameters["ModelViewProjection"].SetValue(viewProjection.ToSharpDX()); - effect.Parameters["AlphaTest"].SetValue(DrawTransparency); - effect.Parameters["Color"].SetValue(Vector4.One); - effect.Parameters["StaticLighting"].SetValue(false); - effect.Parameters["ColoredVertices"].SetValue(false); - effect.Parameters["Texture"].SetResource(_wadRenderer.Texture); - effect.Parameters["TextureSampler"].SetResource(_legacyDevice.SamplerStates.Default); - - for (int i = 0; i < model.Meshes.Count; i++) - { - var mesh = model.Meshes[i]; - if (mesh.Vertices.Count == 0) - continue; - - mesh.UpdateBuffers(Camera.GetPosition()); - - _legacyDevice.SetVertexBuffer(0, mesh.VertexBuffer); - _legacyDevice.SetIndexBuffer(mesh.IndexBuffer, true); - _legacyDevice.SetVertexInputLayout(mesh.InputLayout); - - effect.Parameters["ModelViewProjection"].SetValue(viewProjection.ToSharpDX()); - effect.Techniques[0].Passes[0].Apply(); - - foreach (var submesh in mesh.Submeshes) - { - submesh.Value.Material.SetStates(_legacyDevice, DrawTransparency); - _legacyDevice.DrawIndexed(PrimitiveType.TriangleList, submesh.Value.NumIndices, submesh.Value.BaseIndex); - } - } - } - else if (CurrentObject is WadSpriteSequence) + if (CurrentObject is WadSpriteSequence) { var seq = (WadSpriteSequence)CurrentObject; if (seq.Sprites.Count <= _currentFrame) @@ -384,50 +243,9 @@ protected override void OnDraw() PosEnd = 0.9f * factor } }); } - else if (CurrentObject is ImportedGeometry) + else { - var geo = (ImportedGeometry)CurrentObject; - var model = geo.DirectXModel; - - var effect = DeviceManager.DefaultDeviceManager.___LegacyEffects["RoomGeometry"]; - - effect.Parameters["UseVertexColors"].SetValue(true); - effect.Parameters["AlphaTest"].SetValue(DrawTransparency); - effect.Parameters["Color"].SetValue(Vector4.One); - effect.Parameters["TextureSampler"].SetResource(_legacyDevice.SamplerStates.AnisotropicWrap); - - if (model != null && model.Meshes.Count > 0) - for (int i = 0; i < model.Meshes.Count; i++) - { - var mesh = model.Meshes[i]; - if (mesh.Vertices.Count == 0) - continue; - - mesh.UpdateBuffers(Camera.GetPosition()); - - _legacyDevice.SetVertexBuffer(0, mesh.VertexBuffer); - _legacyDevice.SetIndexBuffer(mesh.IndexBuffer, true); - _legacyDevice.SetVertexInputLayout(mesh.InputLayout); - - effect.Parameters["ModelViewProjection"].SetValue(viewProjection.ToSharpDX()); - - foreach (var submesh in mesh.Submeshes) - { - var texture = submesh.Value.Material.Texture; - if (texture != null && texture is ImportedGeometryTexture) - { - effect.Parameters["TextureEnabled"].SetValue(true); - effect.Parameters["Texture"].SetResource(((ImportedGeometryTexture)texture).DirectXTexture); - effect.Parameters["ReciprocalTextureSize"].SetValue(new Vector2(1.0f / texture.Image.Width, 1.0f / texture.Image.Height)); - } - else - effect.Parameters["TextureEnabled"].SetValue(false); - - effect.Techniques[0].Passes[0].Apply(); - submesh.Value.Material.SetStates(_legacyDevice, DrawTransparency); - _legacyDevice.DrawIndexed(PrimitiveType.TriangleList, submesh.Value.NumIndices, submesh.Value.BaseIndex); - } - } + WadObjectRenderHelper.RenderObject(CurrentObject, _wadRenderer, _legacyDevice, viewProjection, Camera.GetPosition(), DrawTransparency); } } diff --git a/TombLib/TombLib.Forms/Controls/TextureMapBase.cs b/TombLib/TombLib.Forms/Controls/TextureMapBase.cs index ada4d67174..7c4f61a421 100644 --- a/TombLib/TombLib.Forms/Controls/TextureMapBase.cs +++ b/TombLib/TombLib.Forms/Controls/TextureMapBase.cs @@ -186,7 +186,7 @@ protected virtual SelectionPrecisionType GetSelectionPrecision(bool singleVertex return new SelectionPrecisionType(TileSelectionSize, true); } - protected virtual float MaxTextureSize { get; } = 256; + protected virtual float MaxTextureSize { get; } = 1024; private Vector2 Quantize(Vector2 texCoord, bool endX, bool endY, bool singleVertexMovement = false) { diff --git a/TombLib/TombLib.Forms/Controls/WadObjectRenderHelper.cs b/TombLib/TombLib.Forms/Controls/WadObjectRenderHelper.cs new file mode 100644 index 0000000000..8c8df16820 --- /dev/null +++ b/TombLib/TombLib.Forms/Controls/WadObjectRenderHelper.cs @@ -0,0 +1,256 @@ +using SharpDX.Toolkit.Graphics; +using System; +using System.Collections.Generic; +using System.Numerics; +using TombLib.Graphics; +using TombLib.LevelData; +using TombLib.Wad; +using TombLib.Wad.Catalog; + +namespace TombLib.Controls +{ + public static class WadObjectRenderHelper + { + /// + /// Applies optional skin substitute for moveables that need it. + /// If the object is a WadMoveable and has a skin defined in TrCatalog, + /// replaces dummy meshes with the skin's meshes. + /// Returns the original object unchanged for non-moveables or when no skin is found. + /// + public static IWadObject GetRenderObject(IWadObject wadObject, LevelSettings settings) + { + if (wadObject is WadMoveable moveable) + { + var skinId = new WadMoveableId(TrCatalog.GetMoveableSkin(settings.GameVersion, moveable.Id.TypeId)); + var skin = settings.WadTryGetMoveable(skinId); + + if (skin != null && skin != moveable) + return moveable.ReplaceDummyMeshes(skin); + } + return wadObject; + } + + /// + /// Computes a bounding sphere for the given wad object, suitable for camera framing. + /// + public static BoundingSphere ComputeBoundingSphere(IWadObject wadObject, WadRenderer wadRenderer) + { + var bs = new BoundingSphere(new Vector3(0.0f, 256.0f, 0.0f), 640.0f); + + if (wadObject is WadMoveable moveable) + { + if (moveable.Meshes.Count == 0 || (moveable.Meshes.Count == 1 && moveable.Meshes[0] == null)) + return bs; + + var model = wadRenderer.GetMoveable(moveable); + if (model.Animations.Count > 0 && model.Animations[0].KeyFrames.Count > 0) + { + model.UpdateAnimation(0, 0); + var bb = model.Animations[0].KeyFrames[0].CalculateBoundingBox(model, model); + bs = BoundingSphere.FromBoundingBox(bb); + } + } + else if (wadObject is WadStatic staticObj) + { + if (staticObj.Mesh != null) + bs = staticObj.Mesh.CalculateBoundingSphere(); + } + else if (wadObject is ImportedGeometry impGeo) + { + if (impGeo.DirectXModel != null && impGeo.DirectXModel.Meshes != null) + { + var bb = new BoundingBox(); + foreach (var mesh in impGeo.DirectXModel.Meshes) + bb = bb.Union(mesh.BoundingBox); + bs = BoundingSphere.FromBoundingBox(bb); + } + } + + return bs; + } + + /// + /// Creates a camera positioned to frame the given WAD object. + /// Returns null if the object type is unsupported or has no renderable content. + /// + public static ArcBallCamera CreateCameraForObject(IWadObject wadObject, WadRenderer wadRenderer, float fieldOfView) + { + if (wadObject is WadMoveable moveable) + { + if (moveable.Meshes.Count == 0 || (moveable.Meshes.Count == 1 && moveable.Meshes[0] == null)) + return null; + } + else if (wadObject is WadStatic staticObj) + { + if (staticObj.Mesh == null || staticObj.Mesh.VertexPositions.Count == 0) + return null; + } + else if (!(wadObject is WadStatic) && !(wadObject is ImportedGeometry)) + { + return null; + } + + var bs = ComputeBoundingSphere(wadObject, wadRenderer); + var center = bs.Center; + var radius = bs.Radius * 1.15f; + + return new ArcBallCamera(center, MathC.DegToRad(35), MathC.DegToRad(35), + -(float)Math.PI / 2, (float)Math.PI / 2, radius * 3, 50, 1000000, fieldOfView * (float)(Math.PI / 180)); + } + + public static void RenderObject(IWadObject wadObject, WadRenderer wadRenderer, + GraphicsDevice legacyDevice, Matrix4x4 viewProjection, Vector3 cameraPosition, bool drawTransparency) + { + if (wadObject is WadMoveable moveable) + RenderMoveable(moveable, wadRenderer, legacyDevice, viewProjection, cameraPosition, drawTransparency); + else if (wadObject is WadStatic staticObj) + RenderStatic(staticObj, wadRenderer, legacyDevice, viewProjection, cameraPosition, drawTransparency); + else if (wadObject is ImportedGeometry impGeo) + RenderImportedGeometry(impGeo, legacyDevice, viewProjection, cameraPosition, drawTransparency); + } + + public static void RenderMoveable(WadMoveable moveable, WadRenderer wadRenderer, + GraphicsDevice legacyDevice, Matrix4x4 viewProjection, Vector3 cameraPosition, bool drawTransparency) + { + if (moveable.Meshes.Count == 0 || (moveable.Meshes.Count == 1 && moveable.Meshes[0] == null)) + return; + + var model = wadRenderer.GetMoveable(moveable); + model.UpdateAnimation(0, 0); + + var effect = DeviceManager.DefaultDeviceManager.___LegacyEffects["Model"]; + + effect.Parameters["AlphaTest"].SetValue(drawTransparency); + effect.Parameters["Color"].SetValue(Vector4.One); + effect.Parameters["StaticLighting"].SetValue(false); + effect.Parameters["ColoredVertices"].SetValue(false); + effect.Parameters["Texture"].SetResource(wadRenderer.Texture); + effect.Parameters["TextureSampler"].SetResource(legacyDevice.SamplerStates.Default); + + var matrices = new List(); + if (model.Animations.Count != 0) + { + for (var b = 0; b < model.Meshes.Count; b++) + matrices.Add(model.AnimationTransforms[b]); + } + else + { + foreach (var bone in model.Bones) + matrices.Add(bone.GlobalTransform); + } + + if (model.Skin != null) + model.RenderSkin(legacyDevice, effect, viewProjection.ToSharpDX()); + + for (int i = 0; i < model.Meshes.Count; i++) + { + var mesh = model.Meshes[i]; + if (mesh.Vertices.Count == 0) + continue; + + if (model.Skin != null && mesh.Hidden) + continue; + + mesh.UpdateBuffers(cameraPosition); + + legacyDevice.SetVertexBuffer(0, mesh.VertexBuffer); + legacyDevice.SetIndexBuffer(mesh.IndexBuffer, true); + legacyDevice.SetVertexInputLayout(mesh.InputLayout); + + effect.Parameters["ModelViewProjection"].SetValue((matrices[i] * viewProjection).ToSharpDX()); + effect.Techniques[0].Passes[0].Apply(); + + foreach (var submesh in mesh.Submeshes) + { + submesh.Value.Material.SetStates(legacyDevice, drawTransparency); + legacyDevice.Draw(PrimitiveType.TriangleList, submesh.Value.NumIndices, submesh.Value.BaseIndex); + } + } + } + + public static void RenderStatic(WadStatic staticObj, WadRenderer wadRenderer, + GraphicsDevice legacyDevice, Matrix4x4 viewProjection, Vector3 cameraPosition, bool drawTransparency) + { + var model = wadRenderer.GetStatic(staticObj); + + var effect = DeviceManager.DefaultDeviceManager.___LegacyEffects["Model"]; + + effect.Parameters["ModelViewProjection"].SetValue(viewProjection.ToSharpDX()); + effect.Parameters["AlphaTest"].SetValue(drawTransparency); + effect.Parameters["Color"].SetValue(Vector4.One); + effect.Parameters["StaticLighting"].SetValue(false); + effect.Parameters["ColoredVertices"].SetValue(false); + effect.Parameters["Texture"].SetResource(wadRenderer.Texture); + effect.Parameters["TextureSampler"].SetResource(legacyDevice.SamplerStates.Default); + + for (int i = 0; i < model.Meshes.Count; i++) + { + var mesh = model.Meshes[i]; + if (mesh.Vertices.Count == 0) + continue; + + mesh.UpdateBuffers(cameraPosition); + + legacyDevice.SetVertexBuffer(0, mesh.VertexBuffer); + legacyDevice.SetIndexBuffer(mesh.IndexBuffer, true); + legacyDevice.SetVertexInputLayout(mesh.InputLayout); + + effect.Parameters["ModelViewProjection"].SetValue(viewProjection.ToSharpDX()); + effect.Techniques[0].Passes[0].Apply(); + + foreach (var submesh in mesh.Submeshes) + { + submesh.Value.Material.SetStates(legacyDevice, drawTransparency); + legacyDevice.DrawIndexed(PrimitiveType.TriangleList, submesh.Value.NumIndices, submesh.Value.BaseIndex); + } + } + } + + public static void RenderImportedGeometry(ImportedGeometry geo, + GraphicsDevice legacyDevice, Matrix4x4 viewProjection, Vector3 cameraPosition, bool drawTransparency) + { + var model = geo.DirectXModel; + if (model == null || model.Meshes == null || model.Meshes.Count == 0) + return; + + var effect = DeviceManager.DefaultDeviceManager.___LegacyEffects["RoomGeometry"]; + + effect.Parameters["UseVertexColors"].SetValue(true); + effect.Parameters["AlphaTest"].SetValue(drawTransparency); + effect.Parameters["Color"].SetValue(Vector4.One); + effect.Parameters["TextureSampler"].SetResource(legacyDevice.SamplerStates.AnisotropicWrap); + + for (int i = 0; i < model.Meshes.Count; i++) + { + var mesh = model.Meshes[i]; + if (mesh.Vertices.Count == 0) + continue; + + mesh.UpdateBuffers(cameraPosition); + + legacyDevice.SetVertexBuffer(0, mesh.VertexBuffer); + legacyDevice.SetIndexBuffer(mesh.IndexBuffer, true); + legacyDevice.SetVertexInputLayout(mesh.InputLayout); + + effect.Parameters["ModelViewProjection"].SetValue(viewProjection.ToSharpDX()); + + foreach (var submesh in mesh.Submeshes) + { + var texture = submesh.Value.Material.Texture; + if (texture != null && texture is ImportedGeometryTexture) + { + effect.Parameters["TextureEnabled"].SetValue(true); + effect.Parameters["Texture"].SetResource(((ImportedGeometryTexture)texture).DirectXTexture); + effect.Parameters["ReciprocalTextureSize"].SetValue(new Vector2(1.0f / texture.Image.Width, 1.0f / texture.Image.Height)); + } + else + effect.Parameters["TextureEnabled"].SetValue(false); + + effect.Techniques[0].Passes[0].Apply(); + submesh.Value.Material.SetStates(legacyDevice, drawTransparency); + legacyDevice.DrawIndexed(PrimitiveType.TriangleList, submesh.Value.NumIndices, submesh.Value.BaseIndex); + } + } + } + } +} diff --git a/TombLib/TombLib.Forms/Controls/WadTreeControl.cs b/TombLib/TombLib.Forms/Controls/WadTreeControl.cs index 2b4a389d5c..57fee4f260 100644 --- a/TombLib/TombLib.Forms/Controls/WadTreeControl.cs +++ b/TombLib/TombLib.Forms/Controls/WadTreeControl.cs @@ -51,7 +51,7 @@ public WadTreeView() ControlStyles.ResizeRedraw | ControlStyles.UserPaint, true); - tree.SelectedNodes.CollectionChanged += (s, e) => { if (!_changing) SelectedWadObjectIdsChanged?.Invoke(this, EventArgs.Empty); }; + tree.SelectedNodesChanged += (s, e) => { if (!_changing) SelectedWadObjectIdsChanged?.Invoke(this, EventArgs.Empty); }; tbNotes.TextChanged +=(s, e) => { MetadataChanged?.Invoke(this, EventArgs.Empty); }; // Populate game version diff --git a/TombLib/TombLib.Forms/Properties/DesignTimeResources.xaml b/TombLib/TombLib.Forms/Properties/DesignTimeResources.xaml new file mode 100644 index 0000000000..1e614024b1 --- /dev/null +++ b/TombLib/TombLib.Forms/Properties/DesignTimeResources.xaml @@ -0,0 +1,6 @@ + + + + + + diff --git a/TombLib/TombLib.Forms/TombLib.Forms.csproj b/TombLib/TombLib.Forms/TombLib.Forms.csproj index dd5b55c4fe..763fe52055 100644 --- a/TombLib/TombLib.Forms/TombLib.Forms.csproj +++ b/TombLib/TombLib.Forms/TombLib.Forms.csproj @@ -1,4 +1,11 @@  + + + MSBuild:Compile + Designer + true + + net6.0-windows 12 diff --git a/TombLib/TombLib.Forms/Utils/WinFormsUtils.cs b/TombLib/TombLib.Forms/Utils/WinFormsUtils.cs index 1e5dcb2153..db7b3d4b2b 100644 --- a/TombLib/TombLib.Forms/Utils/WinFormsUtils.cs +++ b/TombLib/TombLib.Forms/Utils/WinFormsUtils.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Numerics; using System.Windows.Forms; +using System.Windows.Forms.Integration; namespace TombLib.Utils { @@ -125,7 +126,22 @@ public static IEnumerable BoolCombine(IEnumerable oldObjects, IEnumerab public static bool CurrentControlSupportsInput(Form form, Keys keyData) { - var activeControlType = GetFocusedControl(form)?.GetType().Name; + var activeControl = GetFocusedControl(form); + var activeControlType = activeControl?.GetType().Name; + + if (activeControl is ElementHost && + (keyData.HasFlag(Keys.Control | Keys.A) || + keyData.HasFlag(Keys.Control | Keys.X) || + keyData.HasFlag(Keys.Control | Keys.C) || + keyData.HasFlag(Keys.Control | Keys.V) || + (!keyData.HasFlag(Keys.Control) && !keyData.HasFlag(Keys.Alt)))) + { + var wpfFocused = System.Windows.Input.Keyboard.FocusedElement; + + if (wpfFocused is System.Windows.Controls.TextBox || + wpfFocused is System.Windows.Controls.Primitives.TextBoxBase) + return true; + } if ((keyData.HasFlag(Keys.Control | Keys.A) || keyData.HasFlag(Keys.Control | Keys.X) || diff --git a/TombLib/TombLib.Rendering/FxCompile.cs b/TombLib/TombLib.Rendering/FxCompile.cs deleted file mode 100644 index 6a6880ef6b..0000000000 --- a/TombLib/TombLib.Rendering/FxCompile.cs +++ /dev/null @@ -1,301 +0,0 @@ -//----------------------------------------------------------------------- -// -// Copyright (C) Microsoft Corporation. All rights reserved. -// -//----------------------------------------------------------------------- - -using Microsoft.Build.Framework; -using Microsoft.Build.Utilities; -using System; -using System.Collections; -using System.IO; - -namespace Microsoft.Build.Tasks -{ - /// - /// Task to support Fxc.exe - /// - public class FxCompile : ToolTask - { - /// - /// Constructor - /// - public FxCompile() - { - // Because FxCop wants it this way. - } - - #region Inputs - - /// - /// Sources to be compiled. - /// - /// Required for task to run. - [Required] - public virtual ITaskItem[] Source - { - get => (ITaskItem[])Bag["Sources"]; - set => Bag["Sources"] = value; - } - - /// - /// Gets the collection of parameters used by the derived task class. - /// - /// Parameter bag. - protected internal Hashtable Bag { get; } = new Hashtable(); - - /// - /// Specifies the type of shader. (/T [type]_[model]) - /// ShaderType requires ShaderModel. - /// - /// Consider using one of these: "NotSet", "Effect", "Vertex", "Pixel", "Geometry", "Hull", "Domain", "Compute", or "Texture". - public virtual string ShaderType - { - get => (string)Bag["ShaderType"]; - set => Bag["ShaderType"] = value.ToLowerInvariant() switch - { - "notset" => "", - "effect" => "/T fx", - "vertex" => "/T vs", - "pixel" => "/T ps", - "geometry" => "/T gs", - "hull" => "/T hs", - "domain" => "/T ds", - "compute" => "/T cs", - "texture" => "/T tx", - _ => throw new ArgumentException("ShaderType of " + value + @" is invalid. Consider using one of these: ""NotSet"", ""Effect"", ""Vertex"", ""Pixel"", ""Geometry"", ""Hull"", ""Domain"", ""Compute"", or ""Texture""."), - }; - } - - /// - /// Specifies the shader model. Some shader types can only be used with recent shader models. (/T [type]_[model]) - /// - /// ShaderModel requires ShaderType. - public virtual string ShaderModel - { - get => (string)Bag["ShaderModel"]; - set => Bag["ShaderModel"] = value; - } - - /// - /// Specifies the contents of assembly language output file. (/Fc, /Fx) - /// AssemblerOutput requires AssemblerOutputFile. - /// - /// Consider using one of these: "Assembly Code" or "Assembly Code and Hex". - public virtual string AssemblerOutput - { - get => (string)Bag["AssemblerOutput"]; - set - { - bool isValid = value.Equals("Assembly Code", StringComparison.OrdinalIgnoreCase) - || value.Equals("Assembly Code and Hex", StringComparison.OrdinalIgnoreCase); - - if (!isValid) - throw new ArgumentException("AssemblerOutput of " + value + @" is invalid. Consider using one of these: ""Assembly Code"" or ""Assembly Code and Hex""."); - - Bag["AssemblerOutput"] = value; - } - } - - /// - /// Specifies file name for assembly code listing file. - /// - /// AssemblerOutputFile requires AssemblerOutput. - public virtual string AssemblerOutputFile - { - get => (string)Bag["AssemblerOutputFile"]; - set => Bag["AssemblerOutputFile"] = value; - } - - /// - /// Specifies a name for the variable name in the header file. (/Vn [name]) - /// - public virtual string VariableName - { - get => (string)Bag["VariableName"]; - set => Bag["VariableName"] = value; - } - - /// - /// Specifies a name for header file containing object code. (/Fh [name]) - /// - public virtual string HeaderFileOutput - { - get => (string)Bag["HeaderFileOutput"]; - set => Bag["HeaderFileOutput"] = value; - } - - /// - /// Specifies a name for object file. (/Fo [name]) - /// - public virtual string ObjectFileOutput - { - get => (string)Bag["ObjectFileOutput"]; - set => Bag["ObjectFileOutput"] = value; - } - - /// - /// Defines preprocessing symbols for your source file. - /// - public virtual string[] PreprocessorDefinitions - { - get => (string[])Bag["PreprocessorDefinitions"]; - set => Bag["PreprocessorDefinitions"] = value; - } - - /// - /// Specifies one or more directories to add to the include path; separate with semi-colons if more than one. (/I[path]) - /// - public virtual string[] AdditionalIncludeDirectories - { - get => (string[])Bag["AdditionalIncludeDirectories"]; - set => Bag["AdditionalIncludeDirectories"] = value; - } - - /// - /// Suppresses the display of the startup banner and information message. (/nologo) - /// - public virtual bool SuppressStartupBanner - { - get => GetBoolParameterWithDefault("SuppressStartupBanner", false); - set => Bag["SuppressStartupBanner"] = value; - } - - /// - /// Specifies the name of the entry point for the shader. (/E[name]) - /// - public virtual string EntryPointName - { - get => (string)Bag["EntryPointName"]; - set => Bag["EntryPointName"] = value; - } - - /// - /// Treats all compiler warnings as errors. For a new project, it may be best to use /WX in all compilations; - /// resolving all warnings will ensure the fewest possible hard-to-find code defects. - /// - public virtual bool TreatWarningAsError - { - get => GetBoolParameterWithDefault("TreatWarningAsError", false); - set => Bag["TreatWarningAsError"] = value; - } - - /// - /// Disable optimizations. /Od implies /Gfp though output may not be identical to /Od /Gfp. - /// - public virtual bool DisableOptimizations - { - get => GetBoolParameterWithDefault("DisableOptimizations", false); - set => Bag["DisableOptimizations"] = value; - } - - /// - /// Enable debugging information. - /// - public virtual bool EnableDebuggingInformation - { - get => GetBoolParameterWithDefault("EnableDebuggingInformation", false); - set => Bag["EnableDebuggingInformation"] = value; - } - - /// - /// Path to Windows SDK - /// - public string SdkToolsPath - { - get => (string)Bag["SdkToolsPath"]; - set => Bag["SdkToolsPath"] = value; - } - - /// - /// Name of Fxc.exe - /// - protected override string ToolName => "Fxc.exe"; - - #endregion Inputs - - /// - /// Returns a string with those switches and other information that can't go into a response file and - /// must go directly onto the command line. - /// - /// Called after ValidateParameters and SkipTaskExecution. - protected override string GenerateCommandLineCommands() - { - var commandLineBuilder = new CommandLineBuilderExtension(); - AddCommandLineCommands(commandLineBuilder); - - return commandLineBuilder.ToString(); - } - - /// - /// Returns the command line switch used by the tool executable to specify the response file - /// Will only be called if the task returned a non empty string from GetResponseFileCommands. - /// - /// Called after ValidateParameters, SkipTaskExecution and GetResponseFileCommands. - protected override string GenerateResponseFileCommands() - { - var commandLineBuilder = new CommandLineBuilderExtension(); - AddResponseFileCommands(commandLineBuilder); - - return commandLineBuilder.ToString(); - } - - /// - /// Fills the provided CommandLineBuilderExtension with those switches and other information that can go into a response file. - /// - protected internal virtual void AddResponseFileCommands(CommandLineBuilderExtension commandLine) - { } - - /// - /// Add Command Line Commands - /// - protected internal void AddCommandLineCommands(CommandLineBuilderExtension commandLine) - { - // Order of these affect the order of the command line - - commandLine.AppendSwitchIfNotNull("/I ", AdditionalIncludeDirectories, ""); - commandLine.AppendSwitch(SuppressStartupBanner ? "/nologo" : string.Empty); - commandLine.AppendSwitchIfNotNull("/E", EntryPointName); - commandLine.AppendSwitch(TreatWarningAsError ? "/WX" : string.Empty); - - // Switch cannot be null - if (ShaderType != null && ShaderModel != null) - { - // Shader Model and Type are one switch - commandLine.AppendSwitch(ShaderType + "_" + ShaderModel); - } - - commandLine.AppendSwitchIfNotNull("/D ", PreprocessorDefinitions, ""); - commandLine.AppendSwitchIfNotNull("/Fh ", HeaderFileOutput); - commandLine.AppendSwitchIfNotNull("/Fo ", ObjectFileOutput); - - // Switch cannot be null - if (AssemblerOutput != null) - commandLine.AppendSwitchIfNotNull(AssemblerOutput, AssemblerOutputFile); - - commandLine.AppendSwitchIfNotNull("/Vn ", VariableName); - commandLine.AppendSwitch(DisableOptimizations ? "/Od" : string.Empty); - commandLine.AppendSwitch(EnableDebuggingInformation ? "/Zi" : string.Empty); - - commandLine.AppendSwitchIfNotNull("", Source, " "); - } - - /// - /// Fullpath to the fxc.exe - /// - /// Fullpath to fxc.exe, if found. Otherwise empty or null. - protected override string GenerateFullPathToTool() - => Path.Combine(SdkToolsPath, ToolName); - - /// - /// Get a bool parameter and return a default if its not present - /// in the hash table. - /// - /// JomoF - protected internal bool GetBoolParameterWithDefault(string parameterName, bool defaultValue) - { - object obj = Bag[parameterName]; - return (obj == null) ? defaultValue : (bool)obj; - } - } -} diff --git a/TombLib/TombLib.Rendering/FxCompileEx.targets b/TombLib/TombLib.Rendering/FxCompileEx.targets deleted file mode 100644 index 2f262880cd..0000000000 --- a/TombLib/TombLib.Rendering/FxCompileEx.targets +++ /dev/null @@ -1,176 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - $(PrepareResourcesDependsOn); - FxCompileEx; - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - <_FxCompile Include="@(_EffectShaderWithTargetPath)"> - Effect - 5_0 - - <_FxCompile Include="@(_VertexShaderWithTargetPath)"> - Vertex - 5_0 - - <_FxCompile Include="@(_PixelShaderWithTargetPath)"> - Pixel - 5_0 - - <_FxCompile Include="@(_GeometryShaderWithTargetPath)"> - Geometry - 5_0 - - <_FxCompile Include="@(_HullShaderWithTargetPath)"> - Hull - 5_0 - - <_FxCompile Include="@(_DomainShaderWithTargetPath)"> - Domain - 5_0 - - <_FxCompile Include="@(_ComputeShaderWithTargetPath)"> - Compute - 5_0 - - <_FxCompile Include="@(_TextureShaderWithTargetPath)"> - Texture - 5_0 - - - - - <_FxCompile> - true - $([System.IO.Path]::GetDirectoryName(%(_FxCompile.TargetPath))) - $(IntermediateOutputPath)$([System.IO.Path]::ChangeExtension(%(_FxCompile.TargetPath), '.cso')) - main - true - $(ShaderModel) - - - - - - %(_FxCompile.TargetDirectory)\ - - - - - $(MSBuildProgramFiles32)\Windows Kits\10\bin\$(TargetPlatformVersion)\x86 - $(MSBuildProgramFiles32)\Windows Kits\10\bin\x86 - - - - - - - $(FxCompileDependsOn);AssignItemsFxCompile; - $(AssignTargetPathsDependsOn);AssignItemsFxCompile - - - - - - - - - - - - - - - diff --git a/TombLib/TombLib.Rendering/HlslShaderCompile.targets b/TombLib/TombLib.Rendering/HlslShaderCompile.targets new file mode 100644 index 0000000000..db930705f6 --- /dev/null +++ b/TombLib/TombLib.Rendering/HlslShaderCompile.targets @@ -0,0 +1,160 @@ + + + + $(PrepareResourcesDependsOn); + CompileHlslShaders; + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + <_ShaderCompile Include="@(_EffectShaderWithTargetPath)"> + Effect + 5_0 + + + <_ShaderCompile Include="@(_VertexShaderWithTargetPath)"> + Vertex + 5_0 + + + <_ShaderCompile Include="@(_PixelShaderWithTargetPath)"> + Pixel + 5_0 + + + <_ShaderCompile Include="@(_GeometryShaderWithTargetPath)"> + Geometry + 5_0 + + + <_ShaderCompile Include="@(_HullShaderWithTargetPath)"> + Hull + 5_0 + + + <_ShaderCompile Include="@(_DomainShaderWithTargetPath)"> + Domain + 5_0 + + + <_ShaderCompile Include="@(_ComputeShaderWithTargetPath)"> + Compute + 5_0 + + + <_ShaderCompile Include="@(_TextureShaderWithTargetPath)"> + Texture + 5_0 + + + + + <_ShaderCompile> + $([System.IO.Path]::GetDirectoryName(%(_ShaderCompile.TargetPath))) + $(IntermediateOutputPath)$([System.IO.Path]::ChangeExtension(%(_ShaderCompile.TargetPath), '.cso')) + main + /Zi + fx_%(_ShaderCompile.ShaderModel) + vs_%(_ShaderCompile.ShaderModel) + ps_%(_ShaderCompile.ShaderModel) + gs_%(_ShaderCompile.ShaderModel) + hs_%(_ShaderCompile.ShaderModel) + ds_%(_ShaderCompile.ShaderModel) + cs_%(_ShaderCompile.ShaderModel) + tx_%(_ShaderCompile.ShaderModel) + + + + + + $(AssignTargetPathsDependsOn);AssignShaderCompileItems + + + + + <_WindowsKitsBinDir Condition="'$(_WindowsKitsBinDir)' == ''">$(MSBuildProgramFiles32)\Windows Kits\10\bin + <_FxcArchitecture Condition="'$(PlatformTarget)' == 'x86'">x86 + <_FxcArchitecture Condition="'$(_FxcArchitecture)' == ''">x64 + $(_WindowsKitsBinDir)\$(TargetPlatformVersion)\$(_FxcArchitecture)\fxc.exe + $(_WindowsKitsBinDir)\$(_FxcArchitecture)\fxc.exe + + + + + + + + + + + + + + @(_ResolvedFxcPath) + + + + + + + + + + + + + + + + diff --git a/TombLib/TombLib.Rendering/Rendering/DirectX11/Dx11RenderingDrawingRoom.cs b/TombLib/TombLib.Rendering/Rendering/DirectX11/Dx11RenderingDrawingRoom.cs index 765eed3bb8..e15a62331f 100644 --- a/TombLib/TombLib.Rendering/Rendering/DirectX11/Dx11RenderingDrawingRoom.cs +++ b/TombLib/TombLib.Rendering/Rendering/DirectX11/Dx11RenderingDrawingRoom.cs @@ -31,6 +31,7 @@ public unsafe Dx11RenderingDrawingRoom(Dx11RenderingDevice device, Description d Vector2 textureScaling = new Vector2(16777216.0f) / new Vector2(TextureAllocator.Size.X, TextureAllocator.Size.Y); RoomGeometry roomGeometry = description.Room.RoomGeometry; + float maxTexCoordSpan = description.Room.Level?.IsTombEngine == true ? 1024.0f : 256.0f; // Create buffer Vector3 worldPos = description.Room.WorldPos + description.Offset; @@ -135,8 +136,8 @@ public unsafe Dx11RenderingDrawingRoom(Dx11RenderingDevice device, Description d uvwAndBlendModes[i * 3 + 1] = Dx11RenderingDevice.CompressUvw(position, textureScaling, Vector2.Abs(roomGeometry.VertexEditorUVs[i * 3 + 1]) * (image.Size - VectorInt2.One) + new Vector2(0.5f), (uint)texture.BlendMode); uvwAndBlendModes[i * 3 + 2] = Dx11RenderingDevice.CompressUvw(position, textureScaling, Vector2.Abs(roomGeometry.VertexEditorUVs[i * 3 + 2]) * (image.Size - VectorInt2.One) + new Vector2(0.5f), (uint)texture.BlendMode); } - else if (texture.TriangleCoordsOutOfBounds) - { // Texture is available but coordinates are ouf of bounds + else if (texture.AreTriangleCoordsOutOfBounds(maxTexCoordSpan)) + { // Texture is available but coordinates are out of bounds ImageC image = Dx11RenderingDevice.TextureCoordOutOfBounds; VectorInt3 position = TextureAllocator.Get(image); uvwAndBlendModes[i * 3 + 0] = Dx11RenderingDevice.CompressUvw(position, textureScaling, Vector2.Abs(roomGeometry.VertexEditorUVs[i * 3 + 0]) * (image.Size - VectorInt2.One) + new Vector2(0.5f), (uint)texture.BlendMode); diff --git a/TombLib/TombLib.Rendering/Rendering/DirectX11/Dx11RenderingStateBuffer.cs b/TombLib/TombLib.Rendering/Rendering/DirectX11/Dx11RenderingStateBuffer.cs index f8bf8f18de..10aafa2314 100644 --- a/TombLib/TombLib.Rendering/Rendering/DirectX11/Dx11RenderingStateBuffer.cs +++ b/TombLib/TombLib.Rendering/Rendering/DirectX11/Dx11RenderingStateBuffer.cs @@ -26,6 +26,14 @@ public struct ConstantBufferLayout public int ShowLightingWhiteTextureOnly; [FieldOffset(84)] public int LightMode; + [FieldOffset(88)] + public int BrushShape; // 0=none, 1=circle, 2=square + [FieldOffset(92)] + public float BrushRotation; // Degrees, for rotation indicator line + [FieldOffset(96)] + public Vector4 BrushCenter; // xyz = world center, w = radius + [FieldOffset(112)] + public Vector4 BrushColor; }; public static readonly int Size = ((Marshal.SizeOf(typeof(ConstantBufferLayout)) + 15) / 16) * 16; @@ -54,6 +62,10 @@ public override void Set(RenderingState State) Buffer.ShowExtraBlendingModes = State.ShowExtraBlendingModes ? 1 : 0; Buffer.ShowLightingWhiteTextureOnly = State.ShowLightingWhiteTextureOnly ? 1 : 0; Buffer.LightMode = State.LightMode; + Buffer.BrushShape = State.BrushShape; + Buffer.BrushRotation = State.BrushRotation; + Buffer.BrushCenter = State.BrushCenter; + Buffer.BrushColor = State.BrushColor; Context.UpdateSubresource(ref Buffer, ConstantBuffer); } } diff --git a/TombLib/TombLib.Rendering/Rendering/DirectX11/RoomShaderPS.hlsl b/TombLib/TombLib.Rendering/Rendering/DirectX11/RoomShaderPS.hlsl index 7fd0de1a84..4d433e1d42 100644 --- a/TombLib/TombLib.Rendering/Rendering/DirectX11/RoomShaderPS.hlsl +++ b/TombLib/TombLib.Rendering/Rendering/DirectX11/RoomShaderPS.hlsl @@ -7,6 +7,10 @@ cbuffer WorldData bool ShowExtraBlendingModes; bool ShowLightingWhiteTextureOnly; int LightMode; + int BrushShape; // 0=none, 1=circle, 2=square + float BrushRotation; // Degrees, for rotation indicator line + float4 BrushCenter; // xyz = world center, w = radius + float4 BrushColor; }; struct PixelInputType @@ -18,6 +22,7 @@ struct PixelInputType int BlendMode : BLENDMODE; float2 EditorUv : EDITORUV; int EditorSectorTexture : EDITORSECTORTEXTURE; + float3 WorldPosition : WORLDPOSITION; }; Texture2DArray RoomTexture : register(t0); @@ -35,6 +40,8 @@ float ddAny(float value) return length(float2(ddx(value), ddy(value))); } +#include "../Legacy/BrushOverlay.hlsli" + float4 main(PixelInputType input) : SV_TARGET { int drawOutline = 0; @@ -163,6 +170,9 @@ float4 main(PixelInputType input) : SV_TARGET // Use overlay's alpha as global alpha for any mode (needed for hidden rooms), as we've run out of flag space. result *= input.Overlay.w; + // Draw brush outline projected onto room geometry. + ApplyBrushOverlay(result.xyz, result.w, true, input.Position, input.WorldPosition, RoomGridLineWidth); + if ((result.x + result.y + result.z + result.w) < 0.02f) discard; return result; diff --git a/TombLib/TombLib.Rendering/Rendering/DirectX11/RoomShaderVS.hlsl b/TombLib/TombLib.Rendering/Rendering/DirectX11/RoomShaderVS.hlsl index 126d018b5b..113183932a 100644 --- a/TombLib/TombLib.Rendering/Rendering/DirectX11/RoomShaderVS.hlsl +++ b/TombLib/TombLib.Rendering/Rendering/DirectX11/RoomShaderVS.hlsl @@ -39,6 +39,7 @@ struct PixelInputType int BlendMode : BLENDMODE; float2 EditorUv : EDITORUV; int EditorSectorTexture : EDITORSECTORTEXTURE; + float3 WorldPosition : WORLDPOSITION; }; PixelInputType main(VertexInputType input) @@ -74,6 +75,7 @@ PixelInputType main(VertexInputType input) (int)((input.EditorUv >> 2) << 30) >> 30); // Sign extend; output.EditorSectorTexture = input.EditorUv; output.Overlay = input.Overlay; + output.WorldPosition = input.Position.xyz; return output; } diff --git a/TombLib/TombLib.Rendering/Rendering/Legacy/BrushOverlay.hlsli b/TombLib/TombLib.Rendering/Rendering/Legacy/BrushOverlay.hlsli new file mode 100644 index 0000000000..b03d36ed34 --- /dev/null +++ b/TombLib/TombLib.Rendering/Rendering/Legacy/BrushOverlay.hlsli @@ -0,0 +1,71 @@ +void ApplyBrushOverlay(inout float3 rgb, inout float alpha, bool updateAlpha, float4 svPos, float3 worldPos, float lineWidthFactor) +{ + if (BrushShape == 0) + return; + + float2 delta = worldPos.xz - BrushCenter.xz; + float dist; + if (BrushShape == 1) + dist = length(delta); // Circle + else + dist = max(abs(delta.x), abs(delta.y)); // Square + + float edge = abs(dist - BrushCenter.w); + float lineWidth = (lineWidthFactor * 2048.0f) / svPos.w; + float fw = max(fwidth(dist), 0.001f); + + // Negate-style fill (invert background color inside brush) + + float fillAlpha = step(dist, BrushCenter.w) * BrushColor.w; + + float3 excludeColor = rgb + BrushColor.xyz - 2.0f * rgb * BrushColor.xyz; + float3 diffColor = abs(rgb - BrushColor.xyz); + + // Combine both blend modes + float3 combined = saturate(excludeColor + diffColor); + + rgb = lerp(rgb, combined, fillAlpha); + + if (updateAlpha) + alpha = max(alpha, fillAlpha); + + // White contour ring at the brush edge. + + float outerEdge = edge / fw; + float contourAlpha = saturate(1.0f - outerEdge / max(lineWidth, 0.001f)); + rgb = lerp(rgb, float3(1, 1, 1), contourAlpha); + + if (updateAlpha) + alpha = max(alpha, contourAlpha); + + // Rotation indicator line extending from the brush center. + + if (BrushRotation >= 0.0f) + { + float rotRad = BrushRotation * 3.14159265f / 180.0f; + float2 rotDir = float2(sin(rotRad), cos(rotRad)); + + float along = dot(delta, rotDir); + float perp = abs(dot(delta, float2(-rotDir.y, rotDir.x))); + + float perpFw = max(fwidth(perp), 0.001f); + float perpNorm = perp / perpFw; + + float lineExtent; + + if (BrushShape == 1) // circle + lineExtent = BrushCenter.w; + else // square + lineExtent = BrushCenter.w / max(abs(rotDir.x), abs(rotDir.y)); + + lineExtent = max(lineExtent, 1024.0f); + + float withinLine = step(0.0f, along) * step(along, lineExtent); + + float lineAlpha = saturate(1.0f - perpNorm / max(lineWidth * 0.7f, 0.001f)) * withinLine; + rgb = lerp(rgb, float3(1, 1, 1), lineAlpha); + + if (updateAlpha) + alpha = max(alpha, lineAlpha); + } +} diff --git a/TombLib/TombLib.Rendering/Rendering/Legacy/Model.fx b/TombLib/TombLib.Rendering/Rendering/Legacy/Model.fx index 50c40b253e..e1ac25017e 100644 --- a/TombLib/TombLib.Rendering/Rendering/Legacy/Model.fx +++ b/TombLib/TombLib.Rendering/Rendering/Legacy/Model.fx @@ -16,15 +16,24 @@ struct PixelInputType float4 Position : SV_POSITION; float3 UVW : TEXCOORD0; float4 Color : COLOR; + float3 WorldPosition : WORLDPOSITION; }; float4x4 ModelViewProjection; +float4x4 WorldMatrix; float4x4 Bones[MAX_BONES]; float4 Color; bool AlphaTest; bool StaticLighting; bool ColoredVertices; bool Skinned; +int BrushShape; // 0=none, 1=circle, 2=square +float BrushRotation; // Degrees, for rotation indicator line +float4 BrushCenter; // xyz = world center, w = radius +float4 BrushColor; +float BrushLineWidth; + +#include "BrushOverlay.hlsli" Texture2DArray Texture; sampler TextureSampler; @@ -39,7 +48,7 @@ PixelInputType VS(VertexInputType input) { float totalWeight = dot(input.BoneWeight, 1.0); float4x4 blendedMatrix = (float4x4)0; - + int4 boneIndex = int4( clamp((int)input.BoneIndex.x, 0, MAX_BONES - 1), clamp((int)input.BoneIndex.y, 0, MAX_BONES - 1), @@ -66,8 +75,13 @@ PixelInputType VS(VertexInputType input) } world = mul(blendedMatrix, ModelViewProjection); + output.WorldPosition = mul(float4(input.Position, 1.0f), mul(blendedMatrix, WorldMatrix)).xyz; } - + else + { + output.WorldPosition = mul(float4(input.Position, 1.0f), WorldMatrix).xyz; + } + output.Position = mul(float4(input.Position, 1.0f), world); output.UVW = input.UVW; output.Color = float4(input.Color, 1.0f); @@ -101,6 +115,8 @@ float4 PS(PixelInputType input) : SV_TARGET if (AlphaTest == true && pixel.w <= 0.01f) discard; + ApplyBrushOverlay(pixel.xyz, pixel.w, false, input.Position, input.WorldPosition, BrushLineWidth); + return pixel; } @@ -112,4 +128,4 @@ technique10 Textured SetGeometryShader(NULL); SetPixelShader(CompileShader(ps_4_0, PS())); } -} \ No newline at end of file +} diff --git a/TombLib/TombLib.Rendering/Rendering/RenderingStateBuffer.cs b/TombLib/TombLib.Rendering/Rendering/RenderingStateBuffer.cs index 0f9c9c3697..43e91cdf73 100644 --- a/TombLib/TombLib.Rendering/Rendering/RenderingStateBuffer.cs +++ b/TombLib/TombLib.Rendering/Rendering/RenderingStateBuffer.cs @@ -12,6 +12,12 @@ public class RenderingState public bool ShowExtraBlendingModes = true; public bool ShowLightingWhiteTextureOnly = true; public int LightMode = 0; + + // Object brush overlay (0 = disabled, 1 = circle, 2 = square) + public int BrushShape = 0; + public Vector4 BrushCenter = Vector4.Zero; // xyz = world center, w = radius + public Vector4 BrushColor = Vector4.One; + public float BrushRotation = 0.0f; // Degrees, for rotation indicator line } public abstract class RenderingStateBuffer : IDisposable diff --git a/TombLib/TombLib.Rendering/Rendering/RenderingTextureAllocator.cs b/TombLib/TombLib.Rendering/Rendering/RenderingTextureAllocator.cs index c2827173ac..77909ad9e6 100644 --- a/TombLib/TombLib.Rendering/Rendering/RenderingTextureAllocator.cs +++ b/TombLib/TombLib.Rendering/Rendering/RenderingTextureAllocator.cs @@ -136,7 +136,7 @@ public VectorInt3 Get(RenderingTexture texture) public VectorInt3 GetForTriangle(TextureArea texture) { - const int MaxDirectImageArea = 256 * 256; // @FIXME: why the hell it was 196 * 196 before? + const int MaxDirectImageArea = 256 * 256; // @FIXME: MaxDirectImageArea GREATER comparison against image size area made no sense, // so I changed it to LESS-OR-EQUAL. Addressed to TRTomb. -- Lwmte diff --git a/TombLib/TombLib.Rendering/ResolveFxcPath.ps1 b/TombLib/TombLib.Rendering/ResolveFxcPath.ps1 new file mode 100644 index 0000000000..3d63038dc2 --- /dev/null +++ b/TombLib/TombLib.Rendering/ResolveFxcPath.ps1 @@ -0,0 +1,42 @@ +param( + [string]$BinRoot, + [string]$Architecture = "x64" +) + +$searchRoots = @( + $BinRoot, + (Join-Path ${env:ProgramFiles(x86)} "Windows Kits\10\bin"), + (Join-Path $env:ProgramFiles "Windows Kits\10\bin") +) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique + +foreach ($root in $searchRoots) +{ + if (-not (Test-Path -LiteralPath $root)) + { + continue + } + + $directPath = Join-Path $root (Join-Path $Architecture "fxc.exe") + + if (Test-Path -LiteralPath $directPath) + { + Write-Output $directPath + exit 0 + } + + $versionedPath = Get-ChildItem -LiteralPath $root -Directory -ErrorAction SilentlyContinue | + Sort-Object Name -Descending | + ForEach-Object { + Join-Path $_.FullName (Join-Path $Architecture "fxc.exe") + } | + Where-Object { Test-Path -LiteralPath $_ } | + Select-Object -First 1 + + if ($versionedPath) + { + Write-Output $versionedPath + exit 0 + } +} + +exit 1 diff --git a/TombLib/TombLib.Rendering/TombLib.Rendering.csproj b/TombLib/TombLib.Rendering/TombLib.Rendering.csproj index eaaed83bd7..1e19851327 100644 --- a/TombLib/TombLib.Rendering/TombLib.Rendering.csproj +++ b/TombLib/TombLib.Rendering/TombLib.Rendering.csproj @@ -1,4 +1,4 @@ - + net6.0-windows Library @@ -63,7 +63,7 @@ - + DxShaders.$([System.IO.Path]::GetFileNameWithoutExtension('%(Identity)')) @@ -141,6 +141,9 @@ + + Always + Always @@ -163,9 +166,7 @@ - - - + \ No newline at end of file diff --git a/TombLib/TombLib.Scripting.ClassicScript/Utils/ErrorDetector.cs b/TombLib/TombLib.Scripting.ClassicScript/Utils/ErrorDetector.cs index 85ff17d096..23615135d3 100644 --- a/TombLib/TombLib.Scripting.ClassicScript/Utils/ErrorDetector.cs +++ b/TombLib/TombLib.Scripting.ClassicScript/Utils/ErrorDetector.cs @@ -1,8 +1,8 @@ using ICSharpCode.AvalonEdit.Document; -using Microsoft.Toolkit.HighPerformance; using System; using System.Collections; using System.Collections.Generic; +using System.Linq; using System.Text.RegularExpressions; using TombLib.Scripting.ClassicScript.Parsers; using TombLib.Scripting.ClassicScript.Resources; @@ -226,7 +226,7 @@ private static bool ContainsBrokenNextLines(TextDocument document, int lineOffse nextLine = document.GetLineByNumber(i); nextLineText = LineParser.EscapeComments(document.GetText(nextLine.Offset, nextLine.Length)); - if ((nextLineText.Contains('>') && !Regex.IsMatch(nextLineText, Patterns.NextLineKey)) || nextLineText.Count('>') > 1) + if ((nextLineText.Contains('>') && !Regex.IsMatch(nextLineText, Patterns.NextLineKey)) || nextLineText.Count(c => c == '>') > 1) return true; i++; diff --git a/TombLib/TombLib.WPF/Resources/Localization/EN/TombEditor.json b/TombLib/TombLib.WPF/Resources/Localization/EN/TombEditor.json index 0967ef424b..df6c40f5f0 100644 --- a/TombLib/TombLib.WPF/Resources/Localization/EN/TombEditor.json +++ b/TombLib/TombLib.WPF/Resources/Localization/EN/TombEditor.json @@ -1 +1,68 @@ -{} +{ + "ContentBrowserView": { + "SearchPlaceholder": "Search assets...", + "EmptyStateMessage": "Click here to load a new WAD file.", + "Size": "Size" + }, + + "ContentBrowser": { + "FilterAll": "All", + "FilterMoveables": "Moveables", + "FilterStatics": "Statics", + "FilterImportedGeometry": "Imported Geometry", + "FilterFavorites": "Favorites", + "CategoryMoveables": "Moveables", + "CategoryStatics": "Statics", + "CategoryImportedGeometry": "Imported Geometry", + "CategoryUnknown": "Unknown", + "WadSourceSingle": "From {0}", + "WadSourceMultiple": "From {0} (also in other WADs)", + "ItemCount": "{0} items" + }, + + "ObjectBrushToolboxView": { + "Radius": "Radius:", + "Density": "Density:", + "Rotation": "Rotation:", + "Orthogonal": "Orthogonal", + "RandomRotation": "Random rotation", + "FollowMouse": "Follow mouse", + "RandomScale": "Random scale:", + "FitToFloor": "Fit to floor", + "AlignToGrid": "Align to grid", + "PlaceInAdjacentRooms": "Place in adjacent rooms" + }, + + "ToolBox": { + "GridPaint": "Grid Paint ({0})" + }, + + "ToolBoxView": { + "CircleBrushShape": "Circle Brush Shape", + "SquareBrushShape": "Square Brush Shape", + "Selection": "Selection", + "ObjectSelection": "Object Selection", + "ObjectDeselection": "Object Deselection", + "Brush": "Brush", + "Shovel": "Shovel", + "Pencil": "Pencil", + "Line": "Line", + "Bulldozer": "Bulldozer", + "Smooth": "Smooth", + "Fill": "Fill", + "GroupTexturing": "Group Texturing", + "Eraser": "Eraser", + "Drag": "Drag", + "Ramp": "Ramp", + "QuarterPipe": "Quarter Pipe", + "HalfPipe": "Half Pipe", + "Bowl": "Bowl", + "Pyramid": "Pyramid", + "Terrain": "Terrain", + "TextureEraser": "Eraser", + "Invisibility": "Invisibility", + "PortalDigger": "Portal Digger", + "FixTextureCoordinates": "Fix Texture Coordinates", + "ShowTextures": "Show Textures" + } +} diff --git a/TombLib/TombLib/Catalogs/Engines/TR1/Sounds.xml b/TombLib/TombLib/Catalogs/Engines/TR1/Sounds.xml index 80c37ba800..dfc758838e 100644 --- a/TombLib/TombLib/Catalogs/Engines/TR1/Sounds.xml +++ b/TombLib/TombLib/Catalogs/Engines/TR1/Sounds.xml @@ -147,11 +147,11 @@ - - + + - + diff --git a/TombLib/TombLib/Catalogs/Engines/TR3/Moveables.xml b/TombLib/TombLib/Catalogs/Engines/TR3/Moveables.xml index a5a4520fa2..a8d365f3d5 100644 --- a/TombLib/TombLib/Catalogs/Engines/TR3/Moveables.xml +++ b/TombLib/TombLib/Catalogs/Engines/TR3/Moveables.xml @@ -234,7 +234,7 @@ - +