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 @@
Отмена
Импорт
Экспорт
+ Обновить
Сброс
+ При обновлении все шаблонные расходники будут сброшены к виду по умолчанию. Продолжить?
+ В шаблоне обнаружены расходные элементы, не существующие в проекте. Добавить их?
Вы уверены что хотите сбросить до значений по умолчанию?
+ Не соответствует шаблону. Нажмите кнопку "Обновить" или переименуйте.
Только размещенные в проекте
Выбрать все