diff --git a/src/RevitUnmodelingMep/ViewModels/CategoryAssignmentBuilder.cs b/src/RevitUnmodelingMep/ViewModels/CategoryAssignmentBuilder.cs new file mode 100644 index 000000000..473f263a1 --- /dev/null +++ b/src/RevitUnmodelingMep/ViewModels/CategoryAssignmentBuilder.cs @@ -0,0 +1,119 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; + +using Autodesk.Revit.DB; + +using dosymep.Revit; + +using RevitUnmodelingMep.Models; + +namespace RevitUnmodelingMep.ViewModels; + +internal sealed class CategoryAssignmentBuilder { + private readonly RevitRepository _revitRepository; + + public CategoryAssignmentBuilder(RevitRepository revitRepository) { + _revitRepository = revitRepository; + } + + /// + /// Строит дерево назначений: категории Revit, типы систем и доступные для них конфигурации расходников. + /// + public ObservableCollection Build( + IReadOnlyList categoryOptions, + IEnumerable consumableTypes, + bool onlyPlacedInProject, + Func resolveCategoryId) { + List categories = new List { + BuiltInCategory.OST_PipeCurves, + BuiltInCategory.OST_DuctCurves, + BuiltInCategory.OST_PipeInsulations, + BuiltInCategory.OST_DuctInsulations, + BuiltInCategory.OST_DuctSystem, + BuiltInCategory.OST_PipingSystem + }; + + var assignments = new List(); + + foreach(BuiltInCategory builtInCategory in categories) { + CategoryOption option = categoryOptions.FirstOrDefault(o => o.BuiltInCategory == builtInCategory); + int optionCategoryId = option?.Id ?? (int) builtInCategory; + string categoryName = option?.Name ?? builtInCategory.ToString(); + + List types = CollectionGenerator.GetElementTypesByCategory(_revitRepository.Doc, builtInCategory) + ?? new List(); + if(types.Count == 0) { + continue; + } + + List configsForCategory = consumableTypes? + .Where(c => resolveCategoryId(c) == optionCategoryId) + .OrderBy(c => c?.ConsumableTypeName ?? string.Empty, StringComparer.CurrentCultureIgnoreCase) + .ToList() ?? new List(); + + HashSet placedTypeIds = onlyPlacedInProject ? GetPlacedTypeIds(builtInCategory) : null; + + ObservableCollection systemTypes = + new ObservableCollection( + types + .OfType() + .Where(type => placedTypeIds == null || placedTypeIds.Contains(unchecked((int) type.Id.GetIdValue()))) + .OrderBy(type => type?.Name ?? string.Empty, StringComparer.CurrentCultureIgnoreCase) + .Select(type => CreateSystemTypeItem(type, configsForCategory))); + + if(systemTypes.Count == 0) { + continue; + } + + assignments.Add(new CategoryAssignmentItem { + Name = categoryName, + Category = builtInCategory, + SystemTypes = systemTypes + }); + } + + return new ObservableCollection( + assignments.OrderBy(a => a?.Name ?? string.Empty, StringComparer.CurrentCultureIgnoreCase)); + } + + /// + /// Возвращает идентификаторы типов, которые реально размещены в проекте для указанной категории. + /// + private HashSet GetPlacedTypeIds(BuiltInCategory builtInCategory) { + var result = new HashSet(); + + var collector = new FilteredElementCollector(_revitRepository.Doc) + .OfCategory(builtInCategory) + .WhereElementIsNotElementType(); + + foreach(Element element in collector) { + ElementId typeId = element.GetTypeId(); + if(typeId == null || typeId == ElementId.InvalidElementId) { + continue; + } + + result.Add(unchecked((int) typeId.GetIdValue())); + } + + return result; + } + + /// + /// Создает элемент типа системы с назначениями всех расходников, подходящих для его категории. + /// + private static SystemTypeItem CreateSystemTypeItem(ElementType elementType, List configs) { + int typeId = unchecked((int) elementType.Id.GetIdValue()); + + ObservableCollection configAssignments = + new ObservableCollection( + configs.Select(config => new ConfigAssignmentItem(config, typeId))); + + return new SystemTypeItem { + Name = elementType.Name, + Id = typeId, + Configs = configAssignments + }; + } +} diff --git a/src/RevitUnmodelingMep/ViewModels/CategoryOptionsProvider.cs b/src/RevitUnmodelingMep/ViewModels/CategoryOptionsProvider.cs new file mode 100644 index 000000000..5caa72fbe --- /dev/null +++ b/src/RevitUnmodelingMep/ViewModels/CategoryOptionsProvider.cs @@ -0,0 +1,93 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +using Autodesk.Revit.DB; + +using dosymep.Revit; +using dosymep.SimpleServices; + +namespace RevitUnmodelingMep.ViewModels; + +internal sealed class CategoryOptionsProvider { + private readonly Document _document; + private readonly ILocalizationService _localizationService; + + /// + /// Создает поставщик доступных MEP-категорий для настроек расходников. + /// + public CategoryOptionsProvider(Document document, ILocalizationService localizationService) { + _document = document; + _localizationService = localizationService; + } + + /// + /// Создает справочник категорий, доступных для выбора в настройках расходников. + /// + public IReadOnlyList CreateCategoryOptions() { + return new List { + CreateCategoryOption( + _localizationService.GetLocalizedString("MainViewModel.DuctsName"), + BuiltInCategory.OST_DuctCurves), + CreateCategoryOption( + _localizationService.GetLocalizedString("MainViewModel.PipesName"), + BuiltInCategory.OST_PipeCurves), + CreateCategoryOption( + _localizationService.GetLocalizedString("MainViewModel.PipeInsName"), + BuiltInCategory.OST_PipeInsulations), + CreateCategoryOption( + _localizationService.GetLocalizedString("MainViewModel.DuctInsName"), + BuiltInCategory.OST_DuctInsulations), + CreateCategoryOption( + _localizationService.GetLocalizedString("MainViewModel.DuctSysName"), + BuiltInCategory.OST_DuctSystem), + CreateCategoryOption( + _localizationService.GetLocalizedString("MainViewModel.PipeSysName"), + BuiltInCategory.OST_PipingSystem) + }; + } + + /// + /// Преобразует сохраненное значение категории в один из доступных вариантов выбора. + /// + public CategoryOption ResolveCategoryOption( + IReadOnlyList categoryOptions, + string categoryValue) { + if(string.IsNullOrWhiteSpace(categoryValue)) { + return categoryOptions.FirstOrDefault(); + } + + if(int.TryParse(categoryValue, out int id)) { + return categoryOptions.FirstOrDefault(o => o.Id == id); + } + + string trimmed = categoryValue.Trim(); + if(trimmed.StartsWith("BuiltInCategory.", StringComparison.OrdinalIgnoreCase)) { + string enumName = trimmed.Substring("BuiltInCategory.".Length); + CategoryOption byEnumName = categoryOptions.FirstOrDefault(o => + string.Equals(o.BuiltInCategory.ToString(), enumName, StringComparison.OrdinalIgnoreCase)); + if(byEnumName != null) { + return byEnumName; + } + } + + return categoryOptions.FirstOrDefault(o => + string.Equals(o.Name, categoryValue, StringComparison.OrdinalIgnoreCase)) + ?? categoryOptions.FirstOrDefault(); + } + + /// + /// Создает один вариант категории с локализованным именем, BuiltInCategory и фактическим id категории в документе. + /// + private CategoryOption CreateCategoryOption(string name, BuiltInCategory builtInCategory) { + Category category = Category.GetCategory(_document, builtInCategory); + long idValue = category?.Id?.GetIdValue() ?? (int) builtInCategory; + int id = unchecked((int) idValue); + + return new CategoryOption { + Name = name, + BuiltInCategory = builtInCategory, + Id = id + }; + } +} diff --git a/src/RevitUnmodelingMep/ViewModels/ConsumableTemplateManager.cs b/src/RevitUnmodelingMep/ViewModels/ConsumableTemplateManager.cs new file mode 100644 index 000000000..29dfed925 --- /dev/null +++ b/src/RevitUnmodelingMep/ViewModels/ConsumableTemplateManager.cs @@ -0,0 +1,327 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Linq; +using System.Text.RegularExpressions; + +using RevitUnmodelingMep.Models; + +namespace RevitUnmodelingMep.ViewModels; + +internal sealed class ConsumableTemplateManager { + private readonly VisSettingsStorage _settingsStorage; + private readonly Func _resolveCategoryOption; + private readonly Dictionary _templateConfigs; + private ObservableCollection _items; + + /// + /// Создает менеджер предупреждений и загружает шаблонные расходники для дальнейшего сравнения. + /// + public ConsumableTemplateManager( + VisSettingsStorage settingsStorage, + Func resolveCategoryOption) { + _settingsStorage = settingsStorage; + _resolveCategoryOption = resolveCategoryOption; + _templateConfigs = LoadTemplateConfigs(); + } + + /// + /// Подключает менеджер к текущей коллекции расходников и обновляет все флаги предупреждений. + /// + public void Attach(ObservableCollection items) { + if(ReferenceEquals(_items, items)) { + RefreshWarnings(); + return; + } + + Detach(); + + _items = items; + if(_items == null) { + return; + } + + _items.CollectionChanged += OnItemsCollectionChanged; + foreach(ConsumableTypeItem item in _items) { + AttachItem(item); + } + + RefreshWarnings(); + } + + /// + /// Проверяет, есть ли в шаблоне расходники, которые отсутствуют в текущей коллекции. + /// + public bool HasMissingTemplateItems() { + return _items != null && _templateConfigs.Values.Any(template => !ContainsTemplateItem(template)); + } + + /// + /// Приводит существующие шаблонные расходники к значениям из шаблона и при необходимости добавляет отсутствующие. + /// + public void ApplyTemplatesToItems(bool addMissingItems) { + if(_items == null) { + return; + } + + var usedConfigKeys = new HashSet( + _items + .Select(item => item?.ConfigKey) + .Where(key => !string.IsNullOrWhiteSpace(key)), + StringComparer.OrdinalIgnoreCase); + + foreach(TemplateConsumable template in _templateConfigs.Values) { + ConsumableTypeItem existingItem = _items.FirstOrDefault(item => + string.Equals( + item?.ConsumableTypeName, + template.Config.ConfigName, + StringComparison.CurrentCultureIgnoreCase)); + + if(existingItem != null) { + ApplyTemplateToItem(existingItem, template.Config, keepAssignments: true); + continue; + } + + if(!addMissingItems) { + continue; + } + + string configKey = GetUniqueConfigKey(template.ConfigKey, usedConfigKeys); + usedConfigKeys.Add(configKey); + + ConsumableTypeItem newItem = ConsumableTypeItem.FromConfig(configKey, template.Config); + newItem.SelectedCategory = _resolveCategoryOption?.Invoke(newItem.CategoryId); + _items.Add(newItem); + } + + RefreshWarnings(); + } + + /// + /// Проверяет, есть ли в текущей коллекции расходник с именем указанного шаблонного расходника. + /// + private bool ContainsTemplateItem(TemplateConsumable template) { + return _items.Any(item => + string.Equals( + item?.ConsumableTypeName, + template.Config.ConfigName, + StringComparison.CurrentCultureIgnoreCase)); + } + + /// + /// Снимает подписки с ранее подключенной коллекции и ее элементов. + /// + private void Detach() { + if(_items == null) { + return; + } + + _items.CollectionChanged -= OnItemsCollectionChanged; + foreach(ConsumableTypeItem item in _items) { + DetachItem(item); + } + + _items = null; + } + + /// + /// Загружает шаблонные расходники и индексирует их по названию шаблонного расходника. + /// + private Dictionary LoadTemplateConfigs() { + UnmodelingSettingsDocument defaultSettings = _settingsStorage.GetDefaultSettings(); + IReadOnlyDictionary configs = defaultSettings?.UnmodelingConfig; + + return configs? + .Where(pair => !string.IsNullOrWhiteSpace(pair.Value?.ConfigName)) + .GroupBy(pair => pair.Value.ConfigName, StringComparer.CurrentCultureIgnoreCase) + .ToDictionary( + group => group.Key, + group => new TemplateConsumable(group.First().Key, group.First().Value), + StringComparer.CurrentCultureIgnoreCase) + ?? new Dictionary(StringComparer.CurrentCultureIgnoreCase); + } + + /// + /// Синхронизирует подписки при добавлении и удалении расходников из коллекции. + /// + private void OnItemsCollectionChanged(object sender, NotifyCollectionChangedEventArgs e) { + if(e.OldItems != null) { + foreach(ConsumableTypeItem item in e.OldItems.OfType()) { + DetachItem(item); + } + } + + if(e.NewItems != null) { + foreach(ConsumableTypeItem item in e.NewItems.OfType()) { + AttachItem(item); + RefreshWarning(item); + } + } + } + + /// + /// Подписывается на изменения полей одного расходника, чтобы предупреждение обновлялось сразу. + /// + private void AttachItem(ConsumableTypeItem item) { + if(item == null) { + return; + } + + item.PropertyChanged += OnItemPropertyChanged; + } + + /// + /// Снимает подписку на изменения полей одного расходника. + /// + private void DetachItem(ConsumableTypeItem item) { + if(item == null) { + return; + } + + item.PropertyChanged -= OnItemPropertyChanged; + } + + /// + /// Пересчитывает предупреждение одного расходника после изменения редактируемых полей. + /// + private void OnItemPropertyChanged(object sender, PropertyChangedEventArgs e) { + if(sender is ConsumableTypeItem item && e.PropertyName != nameof(ConsumableTypeItem.IsTemplateDifferenceVisible)) { + RefreshWarning(item); + } + } + + /// + /// Пересчитывает видимость предупреждений для всех подключенных расходников. + /// + private void RefreshWarnings() { + if(_items == null) { + return; + } + + foreach(ConsumableTypeItem item in _items) { + RefreshWarning(item); + } + } + + /// + /// Обновляет флаг предупреждения одного расходника по результату сравнения с шаблоном. + /// + private void RefreshWarning(ConsumableTypeItem item) { + if(item == null) { + return; + } + + item.IsTemplateDifferenceVisible = IsDifferentFromTemplate(item); + } + + /// + /// Проверяет, что расходник все еще совпадает с шаблоном по имени, но отличается редактируемыми значениями. + /// + private bool IsDifferentFromTemplate(ConsumableTypeItem item) { + if(string.IsNullOrWhiteSpace(item.ConsumableTypeName) + || !_templateConfigs.TryGetValue(item.ConsumableTypeName, out TemplateConsumable template)) { + return false; + } + + UnmodelingConfigItem current = item.ToConfigItem(); + UnmodelingConfigItem templateConfig = template.Config; + + return !StringEquals(current.Category, ResolveTemplateCategory(templateConfig.Category)) + || !StringEquals(current.Group, templateConfig.Group) + || !StringEquals(current.Name, templateConfig.Name) + || !StringEquals(current.Mark, templateConfig.Mark) + || !StringEquals(current.Code, templateConfig.Code) + || !StringEquals(current.Unit, templateConfig.Unit) + || !StringEquals(current.Creator, templateConfig.Creator) + || !StringEquals(current.ValueFormula, templateConfig.ValueFormula) + || !StringEquals(current.NoteValue, templateConfig.NoteValue) + || !StringEquals(current.NoteFormat, templateConfig.NoteFormat) + || current.RoundUpTotal != templateConfig.RoundUpTotal + || current.RoundUpNoteTotal != templateConfig.RoundUpNoteTotal; + } + + /// + /// Копирует шаблонные значения в расходник, при необходимости сохраняя текущие назначения на элементы. + /// + private void ApplyTemplateToItem( + ConsumableTypeItem item, + UnmodelingConfigItem template, + bool keepAssignments) { + List assignedElementIds = keepAssignments && item.AssignedElementIds != null + ? new List(item.AssignedElementIds) + : template.AssignedElementIds != null + ? new List(template.AssignedElementIds) + : new List(); + + item.Title = template.ConfigName; + item.ConsumableTypeName = template.ConfigName; + item.Name = template.Name; + item.Grouping = template.Group; + item.Mark = template.Mark; + item.Code = template.Code; + item.Unit = template.Unit; + item.Maker = template.Creator; + item.Formula = template.ValueFormula; + item.NoteValue = template.NoteValue; + item.Note = template.NoteFormat; + item.RoundUpTotal = template.RoundUpTotal; + item.RoundUpNoteTotal = template.RoundUpNoteTotal; + item.SelectedCategory = _resolveCategoryOption?.Invoke(template.Category); + item.CategoryId = item.SelectedCategory?.Id.ToString() ?? template.Category; + item.AssignedElementIds = assignedElementIds; + item.ExtensionData = template.ExtensionData != null + ? new Dictionary(template.ExtensionData) + : null; + } + + /// + /// Возвращает свободный ключ конфигурации: сначала пытается использовать ключ из шаблона, затем генерирует следующий config_NNN. + /// + private string GetUniqueConfigKey(string templateConfigKey, HashSet usedConfigKeys) { + if(!string.IsNullOrWhiteSpace(templateConfigKey) && !usedConfigKeys.Contains(templateConfigKey)) { + return templateConfigKey; + } + + int nextIndex = GetMaxConfigIndex(usedConfigKeys) + 1; + string configKey; + do { + configKey = $"config_{nextIndex:000}"; + nextIndex++; + } while(usedConfigKeys.Contains(configKey)); + + return configKey; + } + + /// + /// Находит максимальный числовой индекс среди ключей вида config_NNN. + /// + private static int GetMaxConfigIndex(IEnumerable configKeys) { + int maxIndex = 0; + foreach(string configKey in configKeys ?? Enumerable.Empty()) { + Match match = Regex.Match(configKey ?? string.Empty, @"config_(\d+)", RegexOptions.IgnoreCase); + if(match.Success && int.TryParse(match.Groups[1].Value, out int index) && index > maxIndex) { + maxIndex = index; + } + } + + return maxIndex; + } + + /// + /// Возвращает идентификатор категории шаблона в том виде, в котором он хранится у редактируемого расходника. + /// + private string ResolveTemplateCategory(string category) { + return _resolveCategoryOption?.Invoke(category)?.Id.ToString() ?? category ?? string.Empty; + } + + /// + /// Сравнивает nullable-строки как пустые строки по правилам текущей культуры, + /// чтобы не показывать предупреждение о расхождении шаблона только из-за разницы между null и пустым значением. + /// + private static bool StringEquals(string left, string right) { + return string.Equals(left ?? string.Empty, right ?? string.Empty, StringComparison.CurrentCulture); + } + +} diff --git a/src/RevitUnmodelingMep/ViewModels/MainViewModel.cs b/src/RevitUnmodelingMep/ViewModels/MainViewModel.cs index 2c58d7bac..aecde1585 100644 --- a/src/RevitUnmodelingMep/ViewModels/MainViewModel.cs +++ b/src/RevitUnmodelingMep/ViewModels/MainViewModel.cs @@ -3,6 +3,7 @@ using System.Collections.ObjectModel; using System.IO; using System.Linq; +using System.Text.RegularExpressions; using System.Windows; using System.Windows.Input; using dosymep.Bim4Everyone.ProjectConfigs; @@ -11,7 +12,6 @@ using dosymep.WPF.ViewModels; using pyRevitLabs.Json; -using Autodesk.Revit.DB; using RevitUnmodelingMep.Models; @@ -26,6 +26,9 @@ internal class MainViewModel : BaseViewModel { private readonly ISaveFileDialogService _saveFileDialogService; public IMessageBoxService MessageBoxService { get; } private readonly IConfigSerializer _configSerializer; + private readonly ConsumableTemplateManager _consumableTemplateManager; + private readonly CategoryOptionsProvider _categoryOptionsProvider; + private readonly CategoryAssignmentBuilder _categoryAssignmentBuilder; private string _errorText; private string _saveProperty; @@ -35,8 +38,7 @@ internal class MainViewModel : BaseViewModel { private int _lastConfigIndex; private readonly IReadOnlyList _categoryOptions; private bool _isViewLoaded; - - + public MainViewModel( PluginConfig pluginConfig, RevitRepository revitRepository, @@ -53,7 +55,12 @@ public MainViewModel( _saveFileDialogService = saveFileDialogService; MessageBoxService = messageBoxService; _configSerializer = configSerializer; - _categoryOptions = CreateCategoryOptions(); + _categoryOptionsProvider = new CategoryOptionsProvider(_revitRepository.Document, _localizationService); + _categoryAssignmentBuilder = new CategoryAssignmentBuilder(_revitRepository); + _categoryOptions = _categoryOptionsProvider.CreateCategoryOptions(); + _consumableTemplateManager = new ConsumableTemplateManager( + _revitRepository.VisSettingsStorage, + ResolveCategoryOption); LoadViewCommand = RelayCommand.Create(LoadView); AcceptViewCommand = RelayCommand.Create(AcceptView, CanAcceptView); @@ -61,6 +68,7 @@ public MainViewModel( RemoveConsumableTypeCommand = RelayCommand.Create(RemoveConsumableType, CanRemoveConsumableType); ImportConfigsCommand = RelayCommand.Create(ImportConfigs); ExportConfigsCommand = RelayCommand.Create(ExportConfigs); + RefreshConfigsCommand = RelayCommand.Create(RefreshConfigs); ResetConfigsCommand = RelayCommand.Create(ResetConfigs); ConsumableTypes = new ObservableCollection(); @@ -83,6 +91,8 @@ public MainViewModel( public ICommand ExportConfigsCommand { get; } + public ICommand RefreshConfigsCommand { get; } + public ICommand ResetConfigsCommand { get; } public HintPanelViewModel Hint { get; } @@ -90,22 +100,37 @@ public MainViewModel( public ISaveFileDialogService SaveFileDialogService => _saveFileDialogService; + /// + /// Хранит текст ошибки валидации, который показывается в нижней части окна. + /// public string ErrorText { get => _errorText; set => RaiseAndSetIfChanged(ref _errorText, value); } + /// + /// Хранит имя параметра, в который сохраняется результат работы настроек. + /// public string SaveProperty { get => _saveProperty; set => RaiseAndSetIfChanged(ref _saveProperty, value); } + /// + /// Хранит редактируемый список расходников и переподключает к нему менеджер шаблонов при замене коллекции. + /// public ObservableCollection ConsumableTypes { get => _consumableTypes; - set => RaiseAndSetIfChanged(ref _consumableTypes, value); + set { + RaiseAndSetIfChanged(ref _consumableTypes, value); + _consumableTemplateManager?.Attach(_consumableTypes); + } } + /// + /// Хранит построенные группы назначений расходников на типы элементов Revit. + /// public ObservableCollection CategoryAssignments { get => _categoryAssignments; set => RaiseAndSetIfChanged(ref _categoryAssignments, value); @@ -114,6 +139,9 @@ public ObservableCollection CategoryAssignments { public IReadOnlyList CategoryOptions => _categoryOptions; public bool IsViewLoaded => _isViewLoaded; + /// + /// Управляет режимом отображения только размещенных в проекте типов и пересобирает списки после изменения. + /// public bool OnlyPlacedInProject { get => _onlyPlacedInProject; set { @@ -130,15 +158,24 @@ public bool OnlyPlacedInProject { } + /// + /// Загружает данные окна после события Loaded. + /// private void LoadView() { LoadConfig(); } + /// + /// Сохраняет настройки после подтверждения окна. + /// private void AcceptView() { SaveConfig(); } + /// + /// Проверяет корректность формул и обязательных настроек перед сохранением окна. + /// private bool CanAcceptView() { bool isValid = FormulaValidator.ValidateFormulas( ConsumableTypes, @@ -153,6 +190,9 @@ private bool CanAcceptView() { } + /// + /// Загружает проектные настройки, настройки расходников и начальные списки назначений. + /// private void LoadConfig() { RevitSettings setting = _pluginConfig.GetSettings(_revitRepository.Document); @@ -163,6 +203,9 @@ private void LoadConfig() { _isViewLoaded = true; } + /// + /// Сохраняет параметр результата, настройки расходников и проектный конфиг плагина. + /// private void SaveConfig() { RevitSettings setting = _pluginConfig.GetSettings(_revitRepository.Document) ?? _pluginConfig.AddSettings(_revitRepository.Document); @@ -172,6 +215,9 @@ private void SaveConfig() { _pluginConfig.SaveProjectConfig(); } + /// + /// Обновляет списки назначений после изменений расходников, когда представление уже загружено. + /// public void RefreshAssignmentsFromConsumableTypes() { if(!_isViewLoaded) { return; @@ -180,6 +226,9 @@ public void RefreshAssignmentsFromConsumableTypes() { UpdateTypesLists(); } + /// + /// Создает новый пользовательский расходник с новым ключом конфигурации и категорией по умолчанию. + /// private void AddConsumableType() { int index = ConsumableTypes.Count + 1; string configKey = GetNextConfigKey(); @@ -197,10 +246,16 @@ private void AddConsumableType() { UpdateTypesLists(); } + /// + /// Разрешает удаление расходника, если в коллекции есть хотя бы один элемент. + /// private bool CanRemoveConsumableType(ConsumableTypeItem item) { return ConsumableTypes?.Count > 0; } + /// + /// Удаляет расходник из коллекции и пересобирает списки назначений. + /// private void RemoveConsumableType(ConsumableTypeItem item) { if(ConsumableTypes == null || ConsumableTypes.Count == 0 || item == null) { return; @@ -221,6 +276,9 @@ private void RemoveConsumableType(ConsumableTypeItem item) { UpdateTypesLists(); } + /// + /// После подтверждения заменяет текущие настройки расходников настройками из файла шаблона. + /// private void ResetConfigs(Window owner) { string message = _localizationService.GetLocalizedString("MainWindow.ResetConfirmMessage"); string title = _localizationService.GetLocalizedString("MainWindow.Title"); @@ -234,6 +292,39 @@ private void ResetConfigs(Window owner) { LoadConsumableTypesFromSettings(defaults); } + /// + /// После подтверждения приводит шаблонные расходники к значениям шаблона и опционально добавляет отсутствующие. + /// + private void RefreshConfigs() { + string title = _localizationService.GetLocalizedString("MainWindow.Title"); + string confirmMessage = _localizationService.GetLocalizedString("MainWindow.RefreshConfirmMessage"); + if(MessageBoxService.Show( + confirmMessage, + title, + MessageBoxButton.OKCancel, + MessageBoxImage.Question) != MessageBoxResult.OK) { + return; + } + + bool addMissingTemplateItems = true; + if(_consumableTemplateManager.HasMissingTemplateItems()) { + string addMissingMessage = _localizationService.GetLocalizedString( + "MainWindow.AddMissingTemplateConsumablesConfirmMessage"); + addMissingTemplateItems = MessageBoxService.Show( + addMissingMessage, + title, + MessageBoxButton.OKCancel, + MessageBoxImage.Question) == MessageBoxResult.OK; + } + + _consumableTemplateManager.ApplyTemplatesToItems(addMissingTemplateItems); + SyncLastConfigIndex(); + UpdateTypesLists(); + } + + /// + /// Импортирует настройки расходников из JSON-файла и заменяет ими текущую коллекцию. + /// private void ImportConfigs() { _openFileDialogService.Filter = "JSON files | *.json"; if(!_openFileDialogService.ShowDialog()) { @@ -255,6 +346,9 @@ private void ImportConfigs() { } } + /// + /// Экспортирует текущие настройки расходников и сопутствующие настройки в JSON-файл. + /// private void ExportConfigs() { _saveFileDialogService.AddExtension = true; _saveFileDialogService.Filter = "JSON files | *.json"; @@ -278,6 +372,9 @@ private void ExportConfigs() { _configSerializer.Serialize(exported)); } + /// + /// Применяет документ настроек к модели окна и заменяет коллекцию расходников отсортированными элементами. + /// private void LoadConsumableTypesFromSettings(UnmodelingSettingsDocument settings) { settings ??= new UnmodelingSettingsDocument(); ApplySettings(settings); @@ -292,6 +389,9 @@ private void LoadConsumableTypesFromSettings(UnmodelingSettingsDocument settings UpdateTypesLists(); } + /// + /// Загружает сохраненные в проекте настройки расходников из хранилища VISSettings. + /// private void LoadUnmodelingConfigs() { UnmodelingSettingsDocument settings = _revitRepository.VisSettingsStorage.GetUnmodelingSettings(); ApplySettings(settings); @@ -305,6 +405,9 @@ private void LoadUnmodelingConfigs() { ConsumableTypes = CreateSortedConsumableTypes(consumableTypes); } + /// + /// Собирает текущие настройки расходников и сохраняет их в хранилище VISSettings. + /// private void SaveUnmodelingConfigs() { UnmodelingSettingsDocument settings = _revitRepository.VisSettingsStorage.GetUnmodelingSettings(); settings ??= new UnmodelingSettingsDocument(); @@ -313,6 +416,9 @@ private void SaveUnmodelingConfigs() { _revitRepository.VisSettingsStorage.SaveUnmodelingSettings(settings); } + /// + /// Преобразует редактируемые расходники окна в словарь конфигурации для хранения и экспорта. + /// private Dictionary BuildUnmodelingConfigs() { var configs = new Dictionary(); if(ConsumableTypes == null) { @@ -330,6 +436,9 @@ private Dictionary BuildUnmodelingConfigs() { return configs; } + /// + /// Создает новую коллекцию расходников, отсортированную по ключу конфигурации. + /// private static ObservableCollection CreateSortedConsumableTypes( IEnumerable consumableTypes) { return new ObservableCollection( @@ -337,119 +446,61 @@ private static ObservableCollection CreateSortedConsumableTy .OrderBy(item => item?.ConfigKey ?? string.Empty, StringComparer.CurrentCultureIgnoreCase)); } + /// + /// Собирает дополнительные настройки раздела немоделируемых элементов. + /// private UnmodelingSettingsOptions BuildUnmodelingSettings() { return new UnmodelingSettingsOptions { OnlyProjectInstances = OnlyPlacedInProject }; } + /// + /// Применяет дополнительные настройки документа к состоянию окна. + /// private void ApplySettings(UnmodelingSettingsDocument settings) { if(settings?.UnmodelingSettings != null) { OnlyPlacedInProject = settings.UnmodelingSettings.OnlyProjectInstances; } } + /// + /// Возвращает следующий ключ конфигурации вида config_NNN и продвигает внутренний счетчик. + /// private string GetNextConfigKey() { _lastConfigIndex++; return $"config_{_lastConfigIndex:000}"; } - private void UpdateTypesLists() { - List categories = new List { - BuiltInCategory.OST_PipeCurves, - BuiltInCategory.OST_DuctCurves, - BuiltInCategory.OST_PipeInsulations, - BuiltInCategory.OST_DuctInsulations, - BuiltInCategory.OST_DuctSystem, - BuiltInCategory.OST_PipingSystem - }; - - var assignments = new List(); - - foreach(BuiltInCategory builtInCategory in categories) { - CategoryOption option = CategoryOptions.FirstOrDefault(o => o.BuiltInCategory == builtInCategory); - int optionCategoryId = option?.Id ?? (int) builtInCategory; - string categoryName = option?.Name ?? builtInCategory.ToString(); - - List types = CollectionGenerator.GetElementTypesByCategory(_revitRepository.Doc, builtInCategory) - ?? new List(); - if(types.Count == 0) { - continue; - } - - List configsForCategory = ConsumableTypes? - .Where(c => TryGetCategoryId(c, out int cid) && cid == optionCategoryId) - .OrderBy(c => c?.ConsumableTypeName ?? string.Empty, StringComparer.CurrentCultureIgnoreCase) - .ToList() ?? new List(); - - HashSet placedTypeIds = OnlyPlacedInProject ? GetPlacedTypeIds(builtInCategory) : null; - - ObservableCollection systemTypes = - new ObservableCollection( - types - .OfType() - .Where(type => placedTypeIds == null || placedTypeIds.Contains(GetElementIdValue(type.Id))) - .OrderBy(type => type?.Name ?? string.Empty, StringComparer.CurrentCultureIgnoreCase) - .Select(type => CreateSystemTypeItem(type, configsForCategory))); - - if(systemTypes.Count == 0) { - continue; + /// + /// Синхронизирует внутренний счетчик ключей после массового добавления или замены расходников. + /// + private void SyncLastConfigIndex() { + int lastConfigIndex = 0; + foreach(ConsumableTypeItem item in ConsumableTypes ?? Enumerable.Empty()) { + Match match = Regex.Match(item?.ConfigKey ?? string.Empty, @"config_(\d+)", RegexOptions.IgnoreCase); + if(match.Success && int.TryParse(match.Groups[1].Value, out int index) && index > lastConfigIndex) { + lastConfigIndex = index; } - - assignments.Add(new CategoryAssignmentItem { - Name = categoryName, - Category = builtInCategory, - SystemTypes = systemTypes - }); } - CategoryAssignments = new ObservableCollection( - assignments.OrderBy(a => a?.Name ?? string.Empty, StringComparer.CurrentCultureIgnoreCase)); + _lastConfigIndex = lastConfigIndex; } - private HashSet GetPlacedTypeIds(BuiltInCategory builtInCategory) { - var result = new HashSet(); - - var collector = new FilteredElementCollector(_revitRepository.Doc) - .OfCategory(builtInCategory) - .WhereElementIsNotElementType(); - - foreach(Element element in collector) { - ElementId typeId = element.GetTypeId(); - if(typeId == null || typeId == ElementId.InvalidElementId) { - continue; - } - - result.Add(GetElementIdValue(typeId)); - } - - return result; - } - - private SystemTypeItem CreateSystemTypeItem(ElementType elementType, List configs) { - int typeId = GetElementIdValue(elementType.Id); - - ObservableCollection configAssignments = - new ObservableCollection( - configs.Select(config => new ConfigAssignmentItem(config, typeId))); - - return new SystemTypeItem { - Name = elementType.Name, - Id = typeId, - Configs = configAssignments - }; - } - - private static int GetElementIdValue(ElementId elementId) { - long value; -#if REVIT_2024_OR_GREATER - value = elementId?.Value ?? 0; -#else - value = elementId?.IntegerValue ?? 0; -#endif - return unchecked((int) value); + /// + /// Строит дерево назначений: категории Revit, типы систем и доступные для них конфигурации расходников. + /// + private void UpdateTypesLists() { + CategoryAssignments = _categoryAssignmentBuilder.Build( + CategoryOptions, + ConsumableTypes, + OnlyPlacedInProject, + ResolveCategoryId); } + /// + /// Пытается получить числовой идентификатор категории из выбранной категории или сохраненного строкового значения. + /// private static bool TryGetCategoryId(ConsumableTypeItem item, out int categoryId) { if(item?.SelectedCategory != null) { categoryId = item.SelectedCategory.Id; @@ -465,75 +516,17 @@ private static bool TryGetCategoryId(ConsumableTypeItem item, out int categoryId return false; } + /// + /// Возвращает nullable-идентификатор категории расходника для валидатора формул. + /// private int? ResolveCategoryId(ConsumableTypeItem item) { return TryGetCategoryId(item, out int cid) ? cid : (int?) null; } - private IReadOnlyList CreateCategoryOptions() { - List options = new List { - - CreateCategoryOption( - _localizationService.GetLocalizedString("MainViewModel.DuctsName"), - BuiltInCategory.OST_DuctCurves), - CreateCategoryOption( - _localizationService.GetLocalizedString("MainViewModel.PipesName"), - BuiltInCategory.OST_PipeCurves), - CreateCategoryOption( - _localizationService.GetLocalizedString("MainViewModel.PipeInsName"), - BuiltInCategory.OST_PipeInsulations), - CreateCategoryOption( - _localizationService.GetLocalizedString("MainViewModel.DuctInsName"), - BuiltInCategory.OST_DuctInsulations), - CreateCategoryOption( - _localizationService.GetLocalizedString("MainViewModel.DuctSysName"), - BuiltInCategory.OST_DuctSystem), - CreateCategoryOption( - _localizationService.GetLocalizedString("MainViewModel.PipeSysName"), - BuiltInCategory.OST_PipingSystem) - }; - - return options; - } - - private CategoryOption CreateCategoryOption(string name, BuiltInCategory builtInCategory) { - Category category = Category.GetCategory(_revitRepository.Document, builtInCategory); - - long idValue; -#if REVIT_2024_OR_GREATER - idValue = category?.Id.Value ?? (long) (int) builtInCategory; -#else - idValue = category?.Id.IntegerValue ?? (int) builtInCategory; -#endif - int id = unchecked((int) idValue); - - return new CategoryOption { - Name = name, - BuiltInCategory = builtInCategory, - Id = id - }; - } - + /// + /// Преобразует сохраненное значение категории в один из доступных вариантов выбора. + /// private CategoryOption ResolveCategoryOption(string categoryValue) { - if(string.IsNullOrWhiteSpace(categoryValue)) { - return CategoryOptions.FirstOrDefault(); - } - - if(int.TryParse(categoryValue, out int id)) { - return CategoryOptions.FirstOrDefault(o => o.Id == id); - } - - string trimmed = categoryValue.Trim(); - if(trimmed.StartsWith("BuiltInCategory.", StringComparison.OrdinalIgnoreCase)) { - string enumName = trimmed.Substring("BuiltInCategory.".Length); - CategoryOption byEnumName = CategoryOptions.FirstOrDefault(o => - string.Equals(o.BuiltInCategory.ToString(), enumName, StringComparison.OrdinalIgnoreCase)); - if(byEnumName != null) { - return byEnumName; - } - } - - return CategoryOptions.FirstOrDefault(o => - string.Equals(o.Name, categoryValue, StringComparison.OrdinalIgnoreCase)) - ?? CategoryOptions.FirstOrDefault(); + return _categoryOptionsProvider.ResolveCategoryOption(CategoryOptions, categoryValue); } } diff --git a/src/RevitUnmodelingMep/ViewModels/TemplateConsumable.cs b/src/RevitUnmodelingMep/ViewModels/TemplateConsumable.cs new file mode 100644 index 000000000..a4c4995cc --- /dev/null +++ b/src/RevitUnmodelingMep/ViewModels/TemplateConsumable.cs @@ -0,0 +1,28 @@ +using RevitUnmodelingMep.Models; + +namespace RevitUnmodelingMep.ViewModels; + +/// +/// Хранит шаблонный расходник вместе с исходным ключом конфигурации, +/// чтобы при добавлении недостающего расходника сохранить ключ из шаблона или проверить его на конфликт. +/// +internal sealed class TemplateConsumable { + /// + /// Создает внутреннее представление шаблонного расходника и подставляет пустую конфигурацию, + /// если из настроек пришло null-значение. + /// + public TemplateConsumable(string configKey, UnmodelingConfigItem config) { + ConfigKey = configKey; + Config = config ?? new UnmodelingConfigItem(); + } + + /// + /// Исходный ключ шаблона в словаре настроек, который используется при создании недостающего расходника. + /// + public string ConfigKey { get; } + + /// + /// Значения шаблона, с которыми сравнивается и синхронизируется редактируемый расходник. + /// + public UnmodelingConfigItem Config { get; } +} diff --git a/src/RevitUnmodelingMep/ViewModels/VMEntities/ConsumableTypeItem.cs b/src/RevitUnmodelingMep/ViewModels/VMEntities/ConsumableTypeItem.cs index a7a4ccb55..4a61c8a88 100644 --- a/src/RevitUnmodelingMep/ViewModels/VMEntities/ConsumableTypeItem.cs +++ b/src/RevitUnmodelingMep/ViewModels/VMEntities/ConsumableTypeItem.cs @@ -22,6 +22,7 @@ internal class ConsumableTypeItem : BaseViewModel { private string _note; private bool _roundUpTotal; private bool _roundUpNoteTotal; + private bool _isTemplateDifferenceVisible; private CategoryOption _selectedCategory; public string Title { @@ -98,6 +99,11 @@ public bool RoundUpNoteTotal { set => RaiseAndSetIfChanged(ref _roundUpNoteTotal, value); } + public bool IsTemplateDifferenceVisible { + get => _isTemplateDifferenceVisible; + set => RaiseAndSetIfChanged(ref _isTemplateDifferenceVisible, value); + } + public CategoryOption SelectedCategory { get => _selectedCategory; set { diff --git a/src/RevitUnmodelingMep/Views/MainWindow.xaml b/src/RevitUnmodelingMep/Views/MainWindow.xaml index e18ff6ce7..65f396006 100644 --- a/src/RevitUnmodelingMep/Views/MainWindow.xaml +++ b/src/RevitUnmodelingMep/Views/MainWindow.xaml @@ -247,7 +247,7 @@ Style="{StaticResource ContentTabControlStyle}" Background="Transparent" BorderThickness="0" - SelectedIndex="0" + SelectedIndex="1" SelectionChanged="ContentTabs_OnSelectionChanged"> @@ -282,12 +282,39 @@ - + - + + + + + + + + + + + + + + @@ -539,6 +566,11 @@ Width="100" Content="{me:LocalizationSource MainWindow.ButtonExport}" Command="{Binding ExportConfigsCommand}" /> + Cancel Import Export + Refresh Reset + Refreshing will reset all template consumables to the default view. Continue? + The template contains consumable items that do not exist in the project. Add them? Are you sure you want to reset to default values? + Does not match the template. Refresh or rename. Only placed in project Select all diff --git a/src/RevitUnmodelingMep/assets/Localization/Language.ru-RU.xaml b/src/RevitUnmodelingMep/assets/Localization/Language.ru-RU.xaml index 404f6f5bd..8a56b4b12 100644 --- a/src/RevitUnmodelingMep/assets/Localization/Language.ru-RU.xaml +++ b/src/RevitUnmodelingMep/assets/Localization/Language.ru-RU.xaml @@ -16,8 +16,12 @@ Отмена Импорт Экспорт + Обновить Сброс + При обновлении все шаблонные расходники будут сброшены к виду по умолчанию. Продолжить? + В шаблоне обнаружены расходные элементы, не существующие в проекте. Добавить их? Вы уверены что хотите сбросить до значений по умолчанию? + Не соответствует шаблону. Нажмите кнопку "Обновить" или переименуйте. Только размещенные в проекте Выбрать все