diff --git a/.github/workflows/publish.RevitSplitMepCurve.yml b/.github/workflows/publish.RevitSplitMepCurve.yml
new file mode 100644
index 000000000..b36efc605
--- /dev/null
+++ b/.github/workflows/publish.RevitSplitMepCurve.yml
@@ -0,0 +1,33 @@
+name: publish RevitSplitMepCurve
+
+on:
+ workflow_dispatch:
+ pull_request:
+ types: [ closed, synchronize, review_requested ]
+ branches: [ main, master ]
+ paths:
+ - '**RevitSplitMepCurve**.cs'
+ - '**RevitSplitMepCurve**.xaml'
+
+env:
+ plugin-name: "RevitSplitMepCurve"
+
+jobs:
+ build:
+ name: build
+ runs-on: windows-latest
+ steps:
+ - uses: actions/checkout@v3
+ with:
+ fetch-depth: 0
+
+ # Install the .NET workload
+ - name: Install .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: 8.0.x
+
+ - name: Run './build.cmd '
+ run: ./build.cmd publish --profile ${{ env.plugin-name }} --pull-request-merged ${{ github.event.pull_request.merged }} --extensions-app-token ${{ secrets.EXTENSIONS_APP_TOKEN }} --revit-plugins-app-token ${{ secrets.REVIT_PLUGINS_APP_TOKEN }}
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.nuke/parameters.RevitSplitMepCurve.json b/.nuke/parameters.RevitSplitMepCurve.json
new file mode 100644
index 000000000..82a6b2871
--- /dev/null
+++ b/.nuke/parameters.RevitSplitMepCurve.json
@@ -0,0 +1,15 @@
+{
+ "$schema": "./build.schema.json",
+ "Solution": "RevitPlugins.slnx",
+ "PluginName": "RevitSplitMepCurve",
+ "PublishDirectory": "01.BIM.extension\\BIM.tab\\bin",
+ "RevitVersions": [
+ "Rv2022",
+ "Rv2023",
+ "Rv2024"
+ ],
+ "IconUrl": "https://icons8.com/icon/22945/cut-paper",
+ "BundleName": "Разделить по уровню СМР",
+ "BundleType": "InvokeButton",
+ "BundleOutput": "01.BIM.extension\\BIM.tab\\СМР.panel\\СМР.pulldown"
+}
diff --git a/RevitPlugins.slnx b/RevitPlugins.slnx
index 6ff59a6f3..316538401 100644
--- a/RevitPlugins.slnx
+++ b/RevitPlugins.slnx
@@ -209,6 +209,9 @@
+
+
+
diff --git a/src/RevitSplitMepCurve/Models/ConnectorConfig.cs b/src/RevitSplitMepCurve/Models/ConnectorConfig.cs
new file mode 100644
index 000000000..306c03518
--- /dev/null
+++ b/src/RevitSplitMepCurve/Models/ConnectorConfig.cs
@@ -0,0 +1,25 @@
+using System;
+
+using Autodesk.Revit.DB;
+
+namespace RevitSplitMepCurve.Models;
+
+internal class ConnectorConfig {
+ public string FamilyName { get; set; } = string.Empty;
+
+ public string SymbolName { get; set; } = string.Empty;
+
+ public bool Equals(FamilySymbol s) {
+ if(s is null) {
+ return false;
+ }
+
+ if(FamilyName is null
+ || SymbolName is null) {
+ return false;
+ }
+
+ return SymbolName.Equals(s.Name, StringComparison.CurrentCultureIgnoreCase)
+ && FamilyName.Equals(s.FamilyName, StringComparison.CurrentCultureIgnoreCase);
+ }
+}
diff --git a/src/RevitSplitMepCurve/Models/Enums/MepClass.cs b/src/RevitSplitMepCurve/Models/Enums/MepClass.cs
new file mode 100644
index 000000000..a83a5a420
--- /dev/null
+++ b/src/RevitSplitMepCurve/Models/Enums/MepClass.cs
@@ -0,0 +1,6 @@
+namespace RevitSplitMepCurve.Models.Enums;
+
+internal enum MepClass {
+ Pipes,
+ Ducts
+}
diff --git a/src/RevitSplitMepCurve/Models/Enums/SelectionMode.cs b/src/RevitSplitMepCurve/Models/Enums/SelectionMode.cs
new file mode 100644
index 000000000..7be1f6073
--- /dev/null
+++ b/src/RevitSplitMepCurve/Models/Enums/SelectionMode.cs
@@ -0,0 +1,7 @@
+namespace RevitSplitMepCurve.Models.Enums;
+
+internal enum SelectionMode {
+ SelectedElements,
+ ActiveView,
+ ActiveDocument
+}
diff --git a/src/RevitSplitMepCurve/Models/Errors/ErrorModel.cs b/src/RevitSplitMepCurve/Models/Errors/ErrorModel.cs
new file mode 100644
index 000000000..54619fe41
--- /dev/null
+++ b/src/RevitSplitMepCurve/Models/Errors/ErrorModel.cs
@@ -0,0 +1,54 @@
+using System;
+
+using Autodesk.Revit.DB;
+
+namespace RevitSplitMepCurve.Models.Errors;
+
+internal class ErrorModel : IEquatable {
+ private readonly ElementId _id;
+
+ public ErrorModel(Element element, string message) {
+ if(string.IsNullOrWhiteSpace(message)) {
+ throw new ArgumentException(nameof(message));
+ }
+ Message = message;
+ Element = element ?? throw new ArgumentNullException(nameof(element));
+ _id = Element.Id;
+ }
+
+ public string Message { get; }
+
+ public Element Element { get; }
+
+ public bool Equals(ErrorModel other) {
+ if(other is null) {
+ return false;
+ }
+
+ if(ReferenceEquals(this, other)) {
+ return true;
+ }
+
+ return Equals(_id, other._id);
+ }
+
+ public override bool Equals(object obj) {
+ if(obj is null) {
+ return false;
+ }
+
+ if(ReferenceEquals(this, obj)) {
+ return true;
+ }
+
+ if(obj.GetType() != GetType()) {
+ return false;
+ }
+
+ return Equals((ErrorModel) obj);
+ }
+
+ public override int GetHashCode() {
+ return _id.GetHashCode();
+ }
+}
diff --git a/src/RevitSplitMepCurve/Models/Exceptions/CannotCreateConnectorException.cs b/src/RevitSplitMepCurve/Models/Exceptions/CannotCreateConnectorException.cs
new file mode 100644
index 000000000..a24e5cec6
--- /dev/null
+++ b/src/RevitSplitMepCurve/Models/Exceptions/CannotCreateConnectorException.cs
@@ -0,0 +1,16 @@
+using System;
+
+namespace RevitSplitMepCurve.Models.Exceptions;
+
+///
+/// Исключение, когда не удалось создать коннектор
+///
+internal class CannotCreateConnectorException : Exception {
+ public CannotCreateConnectorException()
+ : base() {
+ }
+
+ public CannotCreateConnectorException(string msg)
+ : base(msg) {
+ }
+}
diff --git a/src/RevitSplitMepCurve/Models/Exceptions/CannotGetConnectorSymbolException.cs b/src/RevitSplitMepCurve/Models/Exceptions/CannotGetConnectorSymbolException.cs
new file mode 100644
index 000000000..6a6e61889
--- /dev/null
+++ b/src/RevitSplitMepCurve/Models/Exceptions/CannotGetConnectorSymbolException.cs
@@ -0,0 +1,16 @@
+using System;
+
+namespace RevitSplitMepCurve.Models.Exceptions;
+
+///
+/// Исключение, когда не удалось получить типоразмер соединителя для вставки
+///
+internal class CannotGetConnectorSymbolException : Exception {
+ public CannotGetConnectorSymbolException()
+ : base() {
+ }
+
+ public CannotGetConnectorSymbolException(string msg)
+ : base(msg) {
+ }
+}
diff --git a/src/RevitSplitMepCurve/Models/PluginConfig.cs b/src/RevitSplitMepCurve/Models/PluginConfig.cs
new file mode 100644
index 000000000..131f4a9d1
--- /dev/null
+++ b/src/RevitSplitMepCurve/Models/PluginConfig.cs
@@ -0,0 +1,47 @@
+using System.Collections.Generic;
+
+using dosymep.Bim4Everyone;
+using dosymep.Bim4Everyone.ProjectConfigs;
+
+using pyRevitLabs.Json;
+
+using RevitSplitMepCurve.Models.Enums;
+
+namespace RevitSplitMepCurve.Models;
+
+internal class PluginConfig : ProjectConfig {
+ [JsonIgnore]
+ public override string ProjectConfigPath { get; set; }
+
+ [JsonIgnore]
+ public override IConfigSerializer Serializer { get; set; }
+
+ public static PluginConfig GetPluginConfig(IConfigSerializer configSerializer) {
+ return new ProjectConfigBuilder()
+ .SetSerializer(configSerializer)
+ .SetPluginName(nameof(RevitSplitMepCurve))
+ .SetRevitVersion(ModuleEnvironment.RevitVersion)
+ .SetProjectConfigName(nameof(PluginConfig) + ".json")
+ .Build();
+ }
+}
+
+internal class RevitSettings : ProjectSettings {
+ public const MepClass DefaultMepClass = MepClass.Pipes;
+ public const SelectionMode DefaultSelectionMode = SelectionMode.ActiveView;
+
+ public override string ProjectName { get; set; }
+
+ public MepClass SelectedMepClass { get; set; } = DefaultMepClass;
+
+ public SelectionMode SelectedMode { get; set; } = DefaultSelectionMode;
+
+ public ConnectorConfig RoundConnector { get; set; } = new ConnectorConfig();
+
+ public ConnectorConfig RectangleConnector { get; set; } = new ConnectorConfig();
+
+ /// Имена уровней, исключённых пользователем.
+ public List UncheckedLevelNames { get; set; } = [];
+
+ public bool ShowSplitErrors { get; set; } = true;
+}
diff --git a/src/RevitSplitMepCurve/Models/RevitRepository.cs b/src/RevitSplitMepCurve/Models/RevitRepository.cs
new file mode 100644
index 000000000..fccba49f1
--- /dev/null
+++ b/src/RevitSplitMepCurve/Models/RevitRepository.cs
@@ -0,0 +1,197 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+using Autodesk.Revit.ApplicationServices;
+using Autodesk.Revit.DB;
+using Autodesk.Revit.UI;
+
+using dosymep.Revit;
+
+using RevitSplitMepCurve.Models.Enums;
+using RevitSplitMepCurve.Models.Splittable;
+
+namespace RevitSplitMepCurve.Models;
+
+internal class RevitRepository {
+ public RevitRepository(UIApplication uiApplication) {
+ UIApplication = uiApplication ?? throw new ArgumentNullException(nameof(uiApplication));
+ }
+
+ public UIApplication UIApplication { get; }
+
+ public UIDocument ActiveUIDocument => UIApplication.ActiveUIDocument;
+
+ public Application Application => UIApplication.Application;
+
+ public Document Document => ActiveUIDocument.Document;
+
+ public ICollection GetLevels() {
+ return new FilteredElementCollector(Document)
+ .WhereElementIsNotElementType()
+ .OfClass(typeof(Level))
+ .OfType()
+ .ToArray();
+ }
+
+ public ICollection GetConnectorSymbols(BuiltInCategory category, ConnectorProfileType shape) {
+ return GetConnectorSymbols(category)
+ .GroupBy(s => s.Family.Id)
+ .Where(g => IsConnectorFamilyWithShape(g.First().Family, shape))
+ .SelectMany(g => g)
+ .ToArray();
+ }
+
+ ///
+ /// Возвращает все типоразмеры семейств с типом детали "Соединение"
+ ///
+ /// Категория типоразмеров семейств
+ public ICollection GetConnectorSymbols(BuiltInCategory category) {
+ return new FilteredElementCollector(Document)
+ .WhereElementIsElementType()
+ .OfClass(typeof(FamilySymbol))
+ .OfCategory(category)
+ .OfType()
+ .Where(s => s.Family.GetParamValueOrDefault(BuiltInParameter.FAMILY_CONTENT_PART_TYPE)
+ == (int) PartType.Union)
+ .ToArray();
+ }
+
+ /// Все элементы заданной категории во всём документе.
+ public ICollection GetElements(BuiltInCategory category) where T : MEPCurve {
+ return new FilteredElementCollector(Document)
+ .WhereElementIsNotElementType()
+ .OfCategory(category)
+ .OfClass(typeof(T))
+ .OfType()
+ .ToArray();
+ }
+
+ /// Все элементы заданной категории на активном виде.
+ public ICollection GetActiveViewElements(BuiltInCategory category) where T : MEPCurve {
+ return new FilteredElementCollector(Document, Document.ActiveView.Id)
+ .WhereElementIsNotElementType()
+ .OfCategory(category)
+ .OfClass(typeof(T))
+ .OfType()
+ .ToArray();
+ }
+
+ /// Текущий выделенный набор пользователя, отфильтрованный по категории.
+ public ICollection GetSelectedElements(BuiltInCategory category) where T : MEPCurve {
+ return new FilteredElementCollector(Document, ActiveUIDocument.Selection.GetElementIds())
+ .WhereElementIsNotElementType()
+ .OfCategory(category)
+ .OfClass(typeof(T))
+ .OfType()
+ .ToArray();
+ }
+
+ /// true, если у пользователя выбран хотя бы один элемент.
+ public bool HasSelectedElements() {
+ return ActiveUIDocument.Selection.GetElementIds().Count > 0;
+ }
+
+ /// Группы уровней с одинаковыми отметками
+ public ICollection> GetDuplicateLevels() {
+ var levels = GetLevels();
+ return GroupIntersectingLevels(levels);
+ }
+
+ public ICollection GetDisplacementElements() {
+ return new FilteredElementCollector(Document)
+ .WhereElementIsNotElementType()
+ .OfClass(typeof(DisplacementElement))
+ .OfType()
+ .ToArray();
+ }
+
+ public bool IsSyncRequired(ICollection elements) {
+ if(!Document.IsWorkshared) {
+ return false;
+ }
+ foreach(var splittable in elements) {
+ var id = splittable.Element.Id;
+ var checkoutStatus = WorksharingUtils.GetCheckoutStatus(Document, id);
+ if(checkoutStatus == CheckoutStatus.OwnedByOtherUser) {
+ return true;
+ }
+ var updateStatus = WorksharingUtils.GetModelUpdatesStatus(Document, id);
+ if(updateStatus == ModelUpdatesStatus.UpdatedInCentral) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ public void ShowElements(params Element[] elements) {
+ if(elements is null
+ || elements.Length == 0) {
+ return;
+ }
+
+ ElementId[] elementIds = elements
+ .Select(item => item.Id)
+ .ToArray();
+
+ ActiveUIDocument.ShowElements(elementIds);
+ ActiveUIDocument.Selection.SetElementIds(elementIds);
+ }
+
+ ///
+ /// Проверяет, что семейство - соединитель с заданной формой
+ ///
+ /// Семейство
+ /// Форма соединителя
+ /// True, если в семействе ровно 2 соединителя заданной формы, иначе - False
+ private bool IsConnectorFamilyWithShape(Family family, ConnectorProfileType shape) {
+ using var fDoc = Document.EditFamily(family);
+ var connectors = GetConnectors(fDoc);
+ bool result = connectors.Count == 2 && connectors.All(c => c.Shape == shape);
+ fDoc.Close(false);
+ return result;
+ }
+
+ private ICollection GetConnectors(Document familyDocument) {
+ return new FilteredElementCollector(familyDocument)
+ .WhereElementIsNotElementType()
+ .OfClass(typeof(ConnectorElement))
+ .OfType()
+ .ToArray();
+ }
+
+ ///
+ /// Группирует уровни, которые пересекаются по высоте с учетом допуска ревита по длине линий
+ ///
+ private ICollection> GroupIntersectingLevels(ICollection levels) {
+ if(levels == null
+ || !levels.Any()) {
+ return [];
+ }
+
+ double tolerance = Application.ShortCurveTolerance;
+
+ var sortedLevels = levels.OrderBy(l => l.Elevation).ToArray();
+ var groups = new List>();
+ var currentGroup = new List { sortedLevels[0] };
+
+ for(int i = 1; i < sortedLevels.Length; i++) {
+ var prevLevel = sortedLevels[i - 1];
+ var currentLevel = sortedLevels[i];
+
+ double prevTop = prevLevel.Elevation + tolerance;
+ double currentBottom = currentLevel.Elevation - tolerance;
+
+ if(currentBottom <= prevTop) {
+ currentGroup.Add(currentLevel);
+ } else {
+ groups.Add(currentGroup);
+ currentGroup = [currentLevel];
+ }
+ }
+
+ groups.Add(currentGroup);
+
+ return groups;
+ }
+}
diff --git a/src/RevitSplitMepCurve/Models/Settings/ISplitSettings.cs b/src/RevitSplitMepCurve/Models/Settings/ISplitSettings.cs
new file mode 100644
index 000000000..4057f486a
--- /dev/null
+++ b/src/RevitSplitMepCurve/Models/Settings/ISplitSettings.cs
@@ -0,0 +1,16 @@
+using System.Collections.Generic;
+
+using Autodesk.Revit.DB;
+
+namespace RevitSplitMepCurve.Models.Settings;
+
+internal interface ISplitSettings {
+ /// Соединитель для труб и круглых воздуховодов.
+ FamilySymbol ConnectorRoundSymbol { get; }
+
+ /// Соединитель для прямоугольных воздуховодов.
+ FamilySymbol ConnectorRectangleSymbol { get; }
+
+ /// Уровни, на которых надо делить.
+ ICollection Levels { get; }
+}
diff --git a/src/RevitSplitMepCurve/Models/Settings/SplitSettings.cs b/src/RevitSplitMepCurve/Models/Settings/SplitSettings.cs
new file mode 100644
index 000000000..4b69828fe
--- /dev/null
+++ b/src/RevitSplitMepCurve/Models/Settings/SplitSettings.cs
@@ -0,0 +1,19 @@
+using System.Collections.Generic;
+
+using Autodesk.Revit.DB;
+
+namespace RevitSplitMepCurve.Models.Settings;
+
+internal class SplitSettings : ISplitSettings {
+ public SplitSettings(FamilySymbol round, FamilySymbol rectangle, ICollection levels) {
+ ConnectorRoundSymbol = round;
+ ConnectorRectangleSymbol = rectangle;
+ Levels = levels ?? [];
+ }
+
+ public FamilySymbol ConnectorRoundSymbol { get; }
+
+ public FamilySymbol ConnectorRectangleSymbol { get; }
+
+ public ICollection Levels { get; }
+}
diff --git a/src/RevitSplitMepCurve/Models/Splittable/SplitResult.cs b/src/RevitSplitMepCurve/Models/Splittable/SplitResult.cs
new file mode 100644
index 000000000..c09544837
--- /dev/null
+++ b/src/RevitSplitMepCurve/Models/Splittable/SplitResult.cs
@@ -0,0 +1,67 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+using Autodesk.Revit.DB;
+
+using dosymep.Revit;
+
+namespace RevitSplitMepCurve.Models.Splittable;
+
+internal class SplitResult {
+ public SplitResult(
+ MEPCurve original,
+ ICollection newSegments,
+ ICollection insertedConnectors,
+ ICollection displacementElements) {
+ Original = original ?? throw new ArgumentNullException(nameof(original));
+ NewSegments = newSegments ?? [];
+ InsertedConnectors = insertedConnectors ?? [];
+ DisplacementElements = displacementElements ?? [];
+ }
+
+ public MEPCurve Original { get; }
+
+ public ICollection NewSegments { get; }
+
+ public ICollection InsertedConnectors { get; }
+
+ public ICollection DisplacementElements { get; }
+
+ ///
+ /// Обновляет рабочий набор у новых сегментов и коннекторов,
+ /// добавляет их во все DisplacementElement, в которые входил Original.
+ ///
+ public void UpdateSegments() {
+ var sourceWorksetId = Original.WorksetId;
+ Element[] newEls = [..NewSegments, .. InsertedConnectors];
+
+ foreach(var element in newEls) {
+ SetWorksetId(element, sourceWorksetId);
+ }
+
+ foreach(var de in DisplacementElements) {
+ UpdateDisplacementElement(de, newEls);
+ }
+ }
+
+ private void SetWorksetId(Element element, WorksetId worksetId) {
+ if(!element.Document.IsWorkshared) {
+ return;
+ }
+
+ element.SetParamValue(BuiltInParameter.ELEM_PARTITION_PARAM, worksetId.IntegerValue);
+ }
+
+ private void UpdateDisplacementElement(DisplacementElement de, ICollection newEls) {
+ var view = (View) de.Document.GetElement(de.OwnerViewId);
+ HashSet ids = [
+ ..de.GetDisplacedElementIds(),
+ ..newEls.Where(el => DisplacementElement.IsAllowedAsDisplacedElement(el)
+ && !DisplacementElement.IsElementDisplacedInView(view, el.Id))
+ .Select(el => el.Id)
+ ];
+
+ de.SetDisplacedElementIds(ids);
+ }
+}
diff --git a/src/RevitSplitMepCurve/Models/Splittable/SplittableDuct.cs b/src/RevitSplitMepCurve/Models/Splittable/SplittableDuct.cs
new file mode 100644
index 000000000..f288240ba
--- /dev/null
+++ b/src/RevitSplitMepCurve/Models/Splittable/SplittableDuct.cs
@@ -0,0 +1,53 @@
+using System.Collections.Generic;
+
+using Autodesk.Revit.DB;
+using Autodesk.Revit.DB.Mechanical;
+
+using RevitSplitMepCurve.Models.Exceptions;
+using RevitSplitMepCurve.Models.Settings;
+
+namespace RevitSplitMepCurve.Models.Splittable;
+
+internal class SplittableDuct : SplittableElement {
+ private readonly Duct _duct;
+
+ public SplittableDuct(Duct duct, ICollection displacementElements)
+ : base(duct, displacementElements) {
+ _duct = duct;
+ }
+
+ public override SplitResult Split(ISplitSettings settings) {
+ var doc = _duct.Document;
+ var intersections = GetIntersections(settings.Levels);
+
+ var newSegments = new List();
+ var insertedConnectors = new List();
+
+ for(int i = 0; i < intersections.Count; i++) {
+ var point = intersections[i];
+ var newId = MechanicalUtils.BreakCurve(doc, _duct.Id, point);
+ var newDuct = (Duct) doc.GetElement(newId);
+ newSegments.Add(newDuct);
+
+ FamilySymbol connectorSymbol;
+ if(_duct.DuctType.Shape == ConnectorProfileType.Round) {
+ connectorSymbol = settings.ConnectorRoundSymbol;
+ } else if(_duct.DuctType.Shape == ConnectorProfileType.Rectangular) {
+ connectorSymbol = settings.ConnectorRectangleSymbol;
+ } else {
+ throw new CannotGetConnectorSymbolException();
+ }
+
+ var connector1 = GetClosestConnector(_duct, point);
+ var connector2 = GetClosestConnector(newDuct, point);
+ var fitting = InsertConnector(
+ connectorSymbol,
+ newDuct.DuctType,
+ connector1,
+ connector2);
+ insertedConnectors.Add(fitting);
+ }
+
+ return new SplitResult(_duct, newSegments, insertedConnectors, DisplacementElements);
+ }
+}
diff --git a/src/RevitSplitMepCurve/Models/Splittable/SplittableElement.cs b/src/RevitSplitMepCurve/Models/Splittable/SplittableElement.cs
new file mode 100644
index 000000000..054c65897
--- /dev/null
+++ b/src/RevitSplitMepCurve/Models/Splittable/SplittableElement.cs
@@ -0,0 +1,113 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+using Autodesk.Revit.DB;
+
+using RevitSplitMepCurve.Models.Exceptions;
+using RevitSplitMepCurve.Models.Settings;
+
+namespace RevitSplitMepCurve.Models.Splittable;
+
+internal abstract class SplittableElement {
+ protected SplittableElement(MEPCurve element, ICollection displacementElements) {
+ Element = element ?? throw new ArgumentNullException(nameof(element));
+ DisplacementElements = displacementElements ?? [];
+ }
+
+ public MEPCurve Element { get; }
+
+ /// Все DisplacementElement-ы, в которые входит Element.
+ public ICollection DisplacementElements { get; }
+
+ /// true, если кривая пересекает хотя бы один уровень.
+ public bool CanBeSplitted(ICollection levels) {
+ if(levels.Count == 0) {
+ return false;
+ }
+
+ return levels.Any(CanBeSplitted);
+ }
+
+ /// Делит элемент и возвращает результат.
+ public abstract SplitResult Split(ISplitSettings settings);
+
+ protected (XYZ p0, XYZ p1) GetEndPoints() {
+ var curve = ((LocationCurve) Element.Location).Curve;
+ var p0 = curve.GetEndPoint(0);
+ var p1 = curve.GetEndPoint(1);
+ return (p0, p1);
+ }
+
+ protected Connector GetClosestConnector(MEPCurve mepEl, XYZ point) {
+ return mepEl.ConnectorManager.Connectors
+ .OfType()
+ .OrderBy(c => c.Origin.DistanceTo(point))
+ .First();
+ }
+
+ ///
+ /// Точки, по которым надо разбивать осевую линию ВИС элемента.
+ /// Когда элемент ВИС делится - новый кусок будет создаваться со стороны GetEndPoint(0) точки линии.
+ /// Поэтому при последовательном многократном делении одного и того же элемента надо делить в направлении от 0 к 1.
+ protected IList GetIntersections(ICollection levels) {
+ (var p0, var p1) = GetEndPoints();
+ Level[] sortedLevels;
+ XYZ curveMin, curveMax;
+ if(p0.Z <= p1.Z) {
+ sortedLevels = levels.OrderBy(l => l.Elevation).ToArray();
+ (curveMin, curveMax) = (p0, p1);
+ } else {
+ sortedLevels = levels.OrderByDescending(l => l.Elevation).ToArray();
+ (curveMin, curveMax) = (p1, p0);
+ }
+
+ var results = new List();
+ for(int i = 0; i < sortedLevels.Length; i++) {
+ var level = sortedLevels[i];
+ if(!CanBeSplitted(level)) {
+ continue;
+ }
+
+ double curveLengthZ = curveMax.Z - curveMin.Z;
+ double proportion = (level.Elevation - curveMin.Z) / curveLengthZ;
+ var point = curveMin + (curveMax - curveMin) * proportion;
+ results.Add(point);
+ }
+
+ return results;
+ }
+
+ protected FamilyInstance InsertConnector(
+ FamilySymbol connectorSymbol,
+ MEPCurveType mepType,
+ Connector mepEl1,
+ Connector mepEl2) {
+ const RoutingPreferenceRuleGroupType group = RoutingPreferenceRuleGroupType.Unions;
+ var tempRule = new RoutingPreferenceRule(connectorSymbol.Id, "TEMP_UNION_RULE");
+
+ mepType.RoutingPreferenceManager.AddRule(group, tempRule, 0);
+ FamilyInstance createdConnector;
+ try {
+ createdConnector = mepType.Document.Create.NewUnionFitting(mepEl1, mepEl2);
+ } catch(Autodesk.Revit.Exceptions.ArgumentException) {
+ throw new CannotCreateConnectorException();
+ } catch(Autodesk.Revit.Exceptions.InvalidOperationException) {
+ throw new CannotCreateConnectorException();
+ }
+
+ mepType.RoutingPreferenceManager.RemoveRule(group, 0);
+ return createdConnector;
+ }
+
+ private bool CanBeSplitted(Level level) {
+ double tolerance = Element.Document.Application.ShortCurveTolerance;
+ (var p0, var p1) = GetEndPoints();
+ double elMax = Math.Max(p0.Z, p1.Z);
+ double elMin = Math.Min(p0.Z, p1.Z);
+
+ double levelMax = level.Elevation + tolerance;
+ double levelMin = level.Elevation - tolerance;
+ return elMin < levelMin && levelMax < elMax;
+ }
+}
diff --git a/src/RevitSplitMepCurve/Models/Splittable/SplittablePipe.cs b/src/RevitSplitMepCurve/Models/Splittable/SplittablePipe.cs
new file mode 100644
index 000000000..40a9a521e
--- /dev/null
+++ b/src/RevitSplitMepCurve/Models/Splittable/SplittablePipe.cs
@@ -0,0 +1,47 @@
+using System.Collections.Generic;
+
+using Autodesk.Revit.DB;
+using Autodesk.Revit.DB.Plumbing;
+
+using RevitSplitMepCurve.Models.Exceptions;
+using RevitSplitMepCurve.Models.Settings;
+
+namespace RevitSplitMepCurve.Models.Splittable;
+
+internal class SplittablePipe : SplittableElement {
+ private readonly Pipe _pipe;
+
+ public SplittablePipe(Pipe pipe, ICollection displacementElements)
+ : base(pipe, displacementElements) {
+ _pipe = pipe;
+ }
+
+ public override SplitResult Split(ISplitSettings settings) {
+ var doc = _pipe.Document;
+ var intersections = GetIntersections(settings.Levels);
+
+ var newSegments = new List();
+ var insertedConnectors = new List();
+
+ for(int i = 0; i < intersections.Count; i++) {
+ var point = intersections[i];
+ var newId = PlumbingUtils.BreakCurve(doc, _pipe.Id, point);
+ var newPipe = (Pipe) doc.GetElement(newId);
+ newSegments.Add(newPipe);
+
+ var connectorSymbol = settings.ConnectorRoundSymbol
+ ?? throw new CannotGetConnectorSymbolException();
+
+ var connector1 = GetClosestConnector(_pipe, point);
+ var connector2 = GetClosestConnector(newPipe, point);
+ var fitting = InsertConnector(
+ connectorSymbol,
+ newPipe.PipeType,
+ connector1,
+ connector2);
+ insertedConnectors.Add(fitting);
+ }
+
+ return new SplitResult(_pipe, newSegments, insertedConnectors, DisplacementElements);
+ }
+}
diff --git a/src/RevitSplitMepCurve/README.md b/src/RevitSplitMepCurve/README.md
new file mode 100644
index 000000000..78c1517dc
--- /dev/null
+++ b/src/RevitSplitMepCurve/README.md
@@ -0,0 +1,15 @@
+# RevitSplitMepCurve (Разделить по уровню СМР)
+
+Плагин разделяет линейные элементы заданных категорий по этажам.
+
+# Сборка проекта
+
+```
+nuke compile --profile RevitSplitMepCurve
+```
+
+# Публикация проекта
+
+```
+nuke publish --profile RevitSplitMepCurve
+```
diff --git a/src/RevitSplitMepCurve/Resources/ComboBoxBehavior.cs b/src/RevitSplitMepCurve/Resources/ComboBoxBehavior.cs
new file mode 100644
index 000000000..d36883a6b
--- /dev/null
+++ b/src/RevitSplitMepCurve/Resources/ComboBoxBehavior.cs
@@ -0,0 +1,47 @@
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Controls.Primitives;
+
+namespace RevitSplitMepCurve.Resources;
+internal class ComboBoxBehavior {
+ public static readonly DependencyProperty OpenDropDownOnTextChangedProperty =
+ DependencyProperty.RegisterAttached(
+ "OpenDropDownOnTextChanged",
+ typeof(bool),
+ typeof(ComboBoxBehavior),
+ new PropertyMetadata(false, OnOpenDropDownOnTextChangedChanged));
+
+ public static bool GetOpenDropDownOnTextChanged(DependencyObject obj) {
+ return (bool) obj.GetValue(OpenDropDownOnTextChangedProperty);
+ }
+
+ public static void SetOpenDropDownOnTextChanged(DependencyObject obj, bool value) {
+ obj.SetValue(OpenDropDownOnTextChangedProperty, value);
+ }
+
+ private static void OnOpenDropDownOnTextChangedChanged(
+ DependencyObject d,
+ DependencyPropertyChangedEventArgs e) {
+ if(d is ComboBox comboBox) {
+ if((bool) e.NewValue) {
+ comboBox.AddHandler(
+ TextBoxBase.TextChangedEvent,
+ new TextChangedEventHandler(OnTextChanged));
+ } else {
+ comboBox.RemoveHandler(
+ TextBoxBase.TextChangedEvent,
+ new TextChangedEventHandler(OnTextChanged));
+ }
+ }
+ }
+
+ private static void OnTextChanged(object sender, TextChangedEventArgs e) {
+ if(sender is ComboBox comboBox
+ && comboBox.IsEditable
+ && comboBox.IsKeyboardFocusWithin) {
+ if(!string.IsNullOrEmpty(comboBox.Text)) {
+ comboBox.IsDropDownOpen = true;
+ }
+ }
+ }
+}
diff --git a/src/RevitSplitMepCurve/RevitSplitMepCurve.csproj b/src/RevitSplitMepCurve/RevitSplitMepCurve.csproj
new file mode 100644
index 000000000..ae3efaefb
--- /dev/null
+++ b/src/RevitSplitMepCurve/RevitSplitMepCurve.csproj
@@ -0,0 +1,6 @@
+
+
+ true
+ 12
+
+
diff --git a/src/RevitSplitMepCurve/RevitSplitMepCurveCommand.cs b/src/RevitSplitMepCurve/RevitSplitMepCurveCommand.cs
new file mode 100644
index 000000000..a4c27011b
--- /dev/null
+++ b/src/RevitSplitMepCurve/RevitSplitMepCurveCommand.cs
@@ -0,0 +1,88 @@
+using System.Globalization;
+using System.Linq;
+using System.Reflection;
+using System.Windows;
+
+using Autodesk.Revit.Attributes;
+using Autodesk.Revit.UI;
+
+using dosymep.Bim4Everyone;
+using dosymep.Bim4Everyone.ProjectConfigs;
+using dosymep.Bim4Everyone.SimpleServices;
+using dosymep.SimpleServices;
+using dosymep.WpfCore.Ninject;
+using dosymep.WpfUI.Core.Ninject;
+
+using Ninject;
+
+using RevitSplitMepCurve.Models;
+using RevitSplitMepCurve.Services.Core;
+using RevitSplitMepCurve.Services.Providers;
+using RevitSplitMepCurve.ViewModels;
+using RevitSplitMepCurve.Views;
+
+namespace RevitSplitMepCurve;
+
+[Transaction(TransactionMode.Manual)]
+public class RevitSplitMepCurveCommand : BasePluginCommand {
+ public RevitSplitMepCurveCommand() {
+ PluginName = "Разделить по уровню СМР";
+ }
+
+ protected override void Execute(UIApplication uiApplication) {
+ using var kernel = uiApplication.CreatePlatformServices();
+
+ kernel.Bind().ToSelf().InSingletonScope();
+
+ kernel.Bind()
+ .ToMethod(c => PluginConfig.GetPluginConfig(c.Kernel.Get()));
+
+ BindCoreServices(kernel);
+ BindProviders(kernel);
+ BindWindows(kernel);
+
+ kernel.UseWpfUIThemeUpdater();
+ kernel.UseWpfUIMessageBox();
+ kernel.UseWpfUIProgressDialog();
+
+ string assemblyName = Assembly.GetExecutingAssembly().GetName().Name;
+ kernel.UseWpfLocalization(
+ $"/{assemblyName};component/assets/localization/language.xaml",
+ CultureInfo.GetCultureInfo("ru-RU"));
+
+ var localization = kernel.Get();
+ var messageBox = kernel.Get();
+ var repository = kernel.Get();
+
+ var levelDubles = repository.GetDuplicateLevels().FirstOrDefault(g => g.Count > 1);
+ if(levelDubles is not null) {
+ messageBox.Show(
+ localization.GetLocalizedString(
+ "Error.DuplicateLevels",
+ string.Join("\n", levelDubles.Select(l => l.Name))),
+ localization.GetLocalizedString("MainWindow.Title"),
+ MessageBoxButton.OK,
+ MessageBoxImage.Warning);
+ return;
+ }
+
+ Notification(kernel.Get());
+ }
+
+ private void BindCoreServices(IKernel kernel) {
+ kernel.Bind().To().InSingletonScope();
+ kernel.Bind().ToSelf().InSingletonScope();
+ }
+
+ private void BindProviders(IKernel kernel) {
+ kernel.Bind().ToSelf().InSingletonScope();
+ kernel.Bind().ToSelf().InSingletonScope();
+ kernel.Bind().To().InSingletonScope();
+ kernel.Bind().To().InSingletonScope();
+ }
+
+ private void BindWindows(IKernel kernel) {
+ kernel.BindMainWindow();
+ kernel.BindOtherWindow();
+ }
+}
diff --git a/src/RevitSplitMepCurve/Services/Core/ErrorsService.cs b/src/RevitSplitMepCurve/Services/Core/ErrorsService.cs
new file mode 100644
index 000000000..ea99ca9e9
--- /dev/null
+++ b/src/RevitSplitMepCurve/Services/Core/ErrorsService.cs
@@ -0,0 +1,46 @@
+using System;
+using System.Collections.Generic;
+
+using Autodesk.Revit.DB;
+
+using dosymep.SimpleServices;
+
+using RevitSplitMepCurve.Models.Errors;
+
+namespace RevitSplitMepCurve.Services.Core;
+
+internal class ErrorsService : IErrorsService {
+ private readonly List _errors;
+ private readonly ILocalizationService _localizationService;
+
+ public ErrorsService(ILocalizationService localizationService) {
+ _localizationService = localizationService
+ ?? throw new ArgumentNullException(nameof(localizationService));
+ _errors = [];
+ }
+
+ public void AddError(ErrorModel error) {
+ if(error == null) {
+ throw new ArgumentNullException(nameof(error));
+ }
+
+ _errors.Add(error);
+ }
+
+ public void AddError(Element element, string localizationKey) {
+ if(element == null) {
+ throw new ArgumentNullException(nameof(element));
+ }
+
+ if(string.IsNullOrWhiteSpace(localizationKey)) {
+ throw new ArgumentException(nameof(localizationKey));
+ }
+ _errors.Add(new ErrorModel(element, _localizationService.GetLocalizedString(localizationKey)));
+ }
+
+ public ICollection GetErrors() => _errors;
+
+ public bool ContainsErrors() => _errors.Count > 0;
+
+ public void Clear() => _errors.Clear();
+}
diff --git a/src/RevitSplitMepCurve/Services/Core/ErrorsWindowService.cs b/src/RevitSplitMepCurve/Services/Core/ErrorsWindowService.cs
new file mode 100644
index 000000000..5c40f4fb7
--- /dev/null
+++ b/src/RevitSplitMepCurve/Services/Core/ErrorsWindowService.cs
@@ -0,0 +1,20 @@
+using System;
+
+using Ninject;
+using Ninject.Syntax;
+
+using RevitSplitMepCurve.Views;
+
+namespace RevitSplitMepCurve.Services.Core;
+
+internal class ErrorsWindowService {
+ private readonly IResolutionRoot _root;
+
+ public ErrorsWindowService(IResolutionRoot root) {
+ _root = root ?? throw new ArgumentNullException(nameof(root));
+ }
+
+ public void ShowErrorsWindow() {
+ _root.Get().Show();
+ }
+}
diff --git a/src/RevitSplitMepCurve/Services/Core/IErrorsService.cs b/src/RevitSplitMepCurve/Services/Core/IErrorsService.cs
new file mode 100644
index 000000000..e0738cf6c
--- /dev/null
+++ b/src/RevitSplitMepCurve/Services/Core/IErrorsService.cs
@@ -0,0 +1,19 @@
+using System.Collections.Generic;
+
+using Autodesk.Revit.DB;
+
+using RevitSplitMepCurve.Models.Errors;
+
+namespace RevitSplitMepCurve.Services.Core;
+
+internal interface IErrorsService {
+ void AddError(ErrorModel error);
+
+ void AddError(Element element, string localizationKey);
+
+ ICollection GetErrors();
+
+ bool ContainsErrors();
+
+ void Clear();
+}
diff --git a/src/RevitSplitMepCurve/Services/Providers/DuctsProvider.cs b/src/RevitSplitMepCurve/Services/Providers/DuctsProvider.cs
new file mode 100644
index 000000000..1095387ef
--- /dev/null
+++ b/src/RevitSplitMepCurve/Services/Providers/DuctsProvider.cs
@@ -0,0 +1,45 @@
+using System.Collections.Generic;
+using System.Linq;
+
+using Autodesk.Revit.DB;
+using Autodesk.Revit.DB.Mechanical;
+
+using RevitSplitMepCurve.Models;
+using RevitSplitMepCurve.Models.Enums;
+using RevitSplitMepCurve.Models.Splittable;
+
+namespace RevitSplitMepCurve.Services.Providers;
+
+internal class DuctsProvider : ElementsProvider {
+ public DuctsProvider(RevitRepository revitRepository) : base(revitRepository) {
+ }
+
+ public override MepClass MepClass => MepClass.Ducts;
+
+ protected override ICollection GetSelectedElements() {
+ return _revitRepository.GetSelectedElements(BuiltInCategory.OST_DuctCurves)
+ .Where(IsRectangleOrRound)
+ .ToArray();
+ }
+
+ protected override ICollection GetElementsOnActiveView() {
+ return _revitRepository.GetActiveViewElements(BuiltInCategory.OST_DuctCurves)
+ .Where(IsRectangleOrRound)
+ .ToArray();
+ }
+
+ protected override ICollection GetElementsInDocument() {
+ return _revitRepository.GetElements(BuiltInCategory.OST_DuctCurves)
+ .Where(IsRectangleOrRound)
+ .ToArray();
+ }
+
+ protected override SplittableElement CreateSplittable(MEPCurve curve, ICollection all) {
+ var filtered = FilterDisplacements(curve, all);
+ return new SplittableDuct((Duct) curve, filtered);
+ }
+
+ private bool IsRectangleOrRound(Duct duct) {
+ return duct.DuctType?.Shape is ConnectorProfileType.Round or ConnectorProfileType.Rectangular;
+ }
+}
diff --git a/src/RevitSplitMepCurve/Services/Providers/ElementsProvider.cs b/src/RevitSplitMepCurve/Services/Providers/ElementsProvider.cs
new file mode 100644
index 000000000..4382e902c
--- /dev/null
+++ b/src/RevitSplitMepCurve/Services/Providers/ElementsProvider.cs
@@ -0,0 +1,74 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+using Autodesk.Revit.DB;
+
+using RevitSplitMepCurve.Models;
+using RevitSplitMepCurve.Models.Enums;
+using RevitSplitMepCurve.Models.Splittable;
+
+namespace RevitSplitMepCurve.Services.Providers;
+
+internal abstract class ElementsProvider : IElementsProvider {
+ protected readonly RevitRepository _revitRepository;
+
+ protected ElementsProvider(RevitRepository revitRepository) {
+ _revitRepository = revitRepository ?? throw new ArgumentNullException(nameof(revitRepository));
+ }
+
+ public abstract MepClass MepClass { get; }
+
+ public ICollection GetElements(SelectionMode selectionMode) {
+ ICollection raw = selectionMode switch {
+ SelectionMode.SelectedElements => GetSelectedElements(),
+ SelectionMode.ActiveView => GetElementsOnActiveView(),
+ SelectionMode.ActiveDocument => GetElementsInDocument(),
+ _ => throw new ArgumentOutOfRangeException(nameof(selectionMode))
+ };
+
+ var displacements = _revitRepository.GetDisplacementElements();
+ return [.. raw.Where(IsValid).Select(c => CreateSplittable(c, displacements))];
+ }
+
+ protected abstract ICollection GetSelectedElements();
+
+ protected abstract ICollection GetElementsOnActiveView();
+
+ protected abstract ICollection GetElementsInDocument();
+
+ protected abstract SplittableElement CreateSplittable(MEPCurve curve, ICollection all);
+
+ ///
+ /// Фильтрует наборы смещения, в которых есть заданный элемент ВИС
+ ///
+ /// Элемент ВИС
+ /// Все наборы смещения для проверки
+ /// Наборы смещения, в которых есть заданный элемент ВИС
+ protected ICollection FilterDisplacements(
+ MEPCurve curve,
+ ICollection all) {
+ return all.Where(de => de.GetDisplacedElementIds().Contains(curve.Id)).ToArray();
+ }
+
+ ///
+ /// Проверяет, что элемент не короткий и не горизонтальный
+ ///
+ private bool IsValid(MEPCurve mepCurve) {
+ double tolerance = _revitRepository.Application.ShortCurveTolerance;
+ if(mepCurve.Location is not LocationCurve locationCurve) {
+ return false;
+ }
+
+ if(locationCurve.Curve.Length <= tolerance) {
+ return false;
+ }
+
+ if(Math.Abs(locationCurve.Curve.GetEndPoint(0).Z - locationCurve.Curve.GetEndParameter(1))
+ <= tolerance) {
+ return false;
+ }
+
+ return true;
+ }
+}
diff --git a/src/RevitSplitMepCurve/Services/Providers/IElementsProvider.cs b/src/RevitSplitMepCurve/Services/Providers/IElementsProvider.cs
new file mode 100644
index 000000000..6ee2189e6
--- /dev/null
+++ b/src/RevitSplitMepCurve/Services/Providers/IElementsProvider.cs
@@ -0,0 +1,12 @@
+using System.Collections.Generic;
+
+using RevitSplitMepCurve.Models.Enums;
+using RevitSplitMepCurve.Models.Splittable;
+
+namespace RevitSplitMepCurve.Services.Providers;
+
+internal interface IElementsProvider {
+ MepClass MepClass { get; }
+
+ ICollection GetElements(SelectionMode selectionMode);
+}
diff --git a/src/RevitSplitMepCurve/Services/Providers/PipesProvider.cs b/src/RevitSplitMepCurve/Services/Providers/PipesProvider.cs
new file mode 100644
index 000000000..e32ab75a5
--- /dev/null
+++ b/src/RevitSplitMepCurve/Services/Providers/PipesProvider.cs
@@ -0,0 +1,35 @@
+using System.Collections.Generic;
+using System.Linq;
+
+using Autodesk.Revit.DB;
+using Autodesk.Revit.DB.Plumbing;
+
+using RevitSplitMepCurve.Models;
+using RevitSplitMepCurve.Models.Enums;
+using RevitSplitMepCurve.Models.Splittable;
+
+namespace RevitSplitMepCurve.Services.Providers;
+
+internal class PipesProvider : ElementsProvider {
+ public PipesProvider(RevitRepository revitRepository) : base(revitRepository) {
+ }
+
+ public override MepClass MepClass => MepClass.Pipes;
+
+ protected override ICollection GetSelectedElements() {
+ return _revitRepository.GetSelectedElements(BuiltInCategory.OST_PipeCurves).ToArray();
+ }
+
+ protected override ICollection GetElementsOnActiveView() {
+ return _revitRepository.GetActiveViewElements(BuiltInCategory.OST_PipeCurves).ToArray();
+ }
+
+ protected override ICollection GetElementsInDocument() {
+ return _revitRepository.GetElements(BuiltInCategory.OST_PipeCurves).ToArray();
+ }
+
+ protected override SplittableElement CreateSplittable(MEPCurve curve, ICollection all) {
+ var filtered = FilterDisplacements(curve, all);
+ return new SplittablePipe((Pipe) curve, filtered);
+ }
+}
diff --git a/src/RevitSplitMepCurve/ViewModels/Errors/ErrorViewModel.cs b/src/RevitSplitMepCurve/ViewModels/Errors/ErrorViewModel.cs
new file mode 100644
index 000000000..0505c1fe6
--- /dev/null
+++ b/src/RevitSplitMepCurve/ViewModels/Errors/ErrorViewModel.cs
@@ -0,0 +1,23 @@
+using System;
+
+using Autodesk.Revit.DB;
+
+using dosymep.WPF.ViewModels;
+
+using RevitSplitMepCurve.Models.Errors;
+
+namespace RevitSplitMepCurve.ViewModels.Errors;
+
+internal class ErrorViewModel : BaseViewModel {
+ private readonly ErrorModel _error;
+
+ public ErrorViewModel(ErrorModel error) {
+ _error = error ?? throw new ArgumentNullException(nameof(error));
+ }
+
+ public string Message => _error.Message;
+
+ public string ElementName => _error.Element.Name;
+
+ public Element Element => _error.Element;
+}
diff --git a/src/RevitSplitMepCurve/ViewModels/Errors/ErrorsViewModel.cs b/src/RevitSplitMepCurve/ViewModels/Errors/ErrorsViewModel.cs
new file mode 100644
index 000000000..514f70d0b
--- /dev/null
+++ b/src/RevitSplitMepCurve/ViewModels/Errors/ErrorsViewModel.cs
@@ -0,0 +1,47 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Linq;
+using System.Windows.Input;
+
+using Autodesk.Revit.DB;
+
+using dosymep.WPF.Commands;
+using dosymep.WPF.ViewModels;
+
+using RevitSplitMepCurve.Models;
+using RevitSplitMepCurve.Services.Core;
+
+namespace RevitSplitMepCurve.ViewModels.Errors;
+
+internal class ErrorsViewModel : BaseViewModel {
+ private readonly RevitRepository _revitRepository;
+ private readonly IErrorsService _errorsService;
+
+ public ErrorsViewModel(RevitRepository revitRepository, IErrorsService errorsService) {
+ _revitRepository = revitRepository ?? throw new ArgumentNullException(nameof(revitRepository));
+ _errorsService = errorsService ?? throw new ArgumentNullException(nameof(errorsService));
+
+ ShowElementsCommand = RelayCommand.Create(ShowElements, CanShowElements);
+ LoadViewCommand = RelayCommand.Create(LoadView);
+ }
+
+ public ICommand ShowElementsCommand { get; }
+
+ public ICommand LoadViewCommand { get; }
+
+ public ObservableCollection Errors { get; } = [];
+
+ private void ShowElements(ErrorViewModel error) {
+ _revitRepository.ShowElements(error.Element);
+ }
+
+ private bool CanShowElements(ErrorViewModel error) => error is not null;
+
+ private void LoadView() {
+ Errors.Clear();
+ foreach(var error in _errorsService.GetErrors()) {
+ Errors.Add(new ErrorViewModel(error));
+ }
+ }
+}
diff --git a/src/RevitSplitMepCurve/ViewModels/LevelViewModel.cs b/src/RevitSplitMepCurve/ViewModels/LevelViewModel.cs
new file mode 100644
index 000000000..b3fe99147
--- /dev/null
+++ b/src/RevitSplitMepCurve/ViewModels/LevelViewModel.cs
@@ -0,0 +1,62 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+
+using Autodesk.Revit.DB;
+
+using dosymep.SimpleServices;
+using dosymep.WPF.ViewModels;
+
+namespace RevitSplitMepCurve.ViewModels;
+
+internal class LevelViewModel : BaseViewModel, IEquatable {
+ private readonly ILocalizationService _localization;
+ private bool _isSelected;
+
+ ///
+ /// Вью модель уровня
+ ///
+ /// Уровень
+ /// Сервис локализации
+ public LevelViewModel(
+ Level level,
+ ILocalizationService localization) {
+ Level = level ?? throw new ArgumentNullException(nameof(level));
+ _localization = localization ?? throw new ArgumentNullException(nameof(localization));
+
+ double elevationFt = level.Elevation - BasePoint.GetProjectBasePoint(level.Document).Position.Z;
+ double elevationMm = UnitUtils.ConvertFromInternalUnits(elevationFt, UnitTypeId.Millimeters);
+ DisplayName = _localization.GetLocalizedString("MainWindow.Levels.DisplayFormat", level.Name, elevationMm);
+ }
+
+ public string Name => Level.Name;
+
+ public string DisplayName { get; }
+
+ public Level Level { get; }
+
+ public bool IsSelected {
+ get => _isSelected;
+ set => RaiseAndSetIfChanged(ref _isSelected, value);
+ }
+
+ public bool Equals(LevelViewModel other) {
+ if(other is null) {
+ return false;
+ }
+
+ if(ReferenceEquals(this, other)) {
+ return true;
+ }
+
+ return Level.Id == other.Level.Id;
+ }
+
+ public override bool Equals(object obj) {
+ return Equals(obj as LevelViewModel);
+ }
+
+ public override int GetHashCode() {
+ return EqualityComparer.Default.GetHashCode(Level.Id);
+ }
+}
diff --git a/src/RevitSplitMepCurve/ViewModels/MainViewModel.cs b/src/RevitSplitMepCurve/ViewModels/MainViewModel.cs
new file mode 100644
index 000000000..5f9795eeb
--- /dev/null
+++ b/src/RevitSplitMepCurve/ViewModels/MainViewModel.cs
@@ -0,0 +1,336 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.ComponentModel;
+using System.Linq;
+using System.Windows;
+using System.Windows.Input;
+
+using dosymep.Revit;
+using dosymep.SimpleServices;
+using dosymep.WPF.Commands;
+using dosymep.WPF.ViewModels;
+
+using RevitSplitMepCurve.Models;
+using RevitSplitMepCurve.Models.Enums;
+using RevitSplitMepCurve.Models.Errors;
+using RevitSplitMepCurve.Models.Exceptions;
+using RevitSplitMepCurve.Models.Splittable;
+using RevitSplitMepCurve.Services.Core;
+using RevitSplitMepCurve.Services.Providers;
+using RevitSplitMepCurve.ViewModels.Providers;
+
+using Autodesk.Revit.DB;
+
+namespace RevitSplitMepCurve.ViewModels;
+
+internal class MainViewModel : BaseViewModel {
+ public IProgressDialogFactory ProgressFactory { get; }
+ private readonly PluginConfig _pluginConfig;
+ private readonly RevitRepository _revitRepository;
+ private readonly IErrorsService _errorsService;
+ private readonly ILocalizationService _localization;
+ private readonly ErrorsWindowService _errorsWindowService;
+ private readonly IMessageBoxService _messageBoxService;
+ private readonly ICollection _providers;
+
+ private SelectionModeViewModel _selectionMode;
+ private ElementsProviderViewModel _elementsProvider;
+ private bool _showPlacingErrors;
+ private bool _selectAllLevels;
+ private string _errorText;
+
+ public MainViewModel(
+ PluginConfig pluginConfig,
+ RevitRepository revitRepository,
+ IErrorsService errorsService,
+ ILocalizationService localization,
+ ErrorsWindowService errorsWindowService,
+ IMessageBoxService messageBoxService,
+ IProgressDialogFactory progressFactory,
+ ICollection providers) {
+ ProgressFactory = progressFactory ?? throw new ArgumentNullException(nameof(progressFactory));
+ _pluginConfig = pluginConfig ?? throw new ArgumentNullException(nameof(pluginConfig));
+ _revitRepository = revitRepository ?? throw new ArgumentNullException(nameof(revitRepository));
+ _errorsService = errorsService ?? throw new ArgumentNullException(nameof(errorsService));
+ _localization = localization ?? throw new ArgumentNullException(nameof(localization));
+ _errorsWindowService = errorsWindowService ?? throw new ArgumentNullException(nameof(errorsWindowService));
+ _messageBoxService = messageBoxService ?? throw new ArgumentNullException(nameof(messageBoxService));
+ _providers = providers ?? throw new ArgumentNullException(nameof(providers));
+
+ Levels = [];
+ AvailableSelectionModes = [];
+ AvailableElementsProviders = [];
+
+ LoadViewCommand = RelayCommand.Create(LoadView);
+ AcceptViewCommand = RelayCommand.Create(AcceptView, CanAcceptView);
+ }
+
+ public ICommand LoadViewCommand { get; }
+
+ public ICommand AcceptViewCommand { get; }
+
+ public IMessageBoxService MessageBoxService => _messageBoxService;
+
+ public SelectionModeViewModel SelectionMode {
+ get => _selectionMode;
+ set => RaiseAndSetIfChanged(ref _selectionMode, value);
+ }
+
+ public ObservableCollection AvailableSelectionModes { get; }
+
+ public ElementsProviderViewModel ElementsProvider {
+ get => _elementsProvider;
+ set => RaiseAndSetIfChanged(ref _elementsProvider, value);
+ }
+
+ public ObservableCollection AvailableElementsProviders { get; }
+
+ public ObservableCollection Levels { get; }
+
+ public bool ShowPlacingErrors {
+ get => _showPlacingErrors;
+ set => RaiseAndSetIfChanged(ref _showPlacingErrors, value);
+ }
+
+ public bool SelectAllLevels {
+ get => _selectAllLevels;
+ set {
+ if(_selectAllLevels == value) {
+ return;
+ }
+ RaiseAndSetIfChanged(ref _selectAllLevels, value);
+ foreach(var level in Levels) {
+ level.IsSelected = value;
+ }
+ }
+ }
+
+ public string ErrorText {
+ get => _errorText;
+ private set => RaiseAndSetIfChanged(ref _errorText, value);
+ }
+
+ private void LoadView() {
+ LoadConfig();
+ }
+
+ private PipesProviderViewModel CreatePipesProvider(PipesProvider provider, RevitSettings config) {
+ var providerVm = new PipesProviderViewModel(_localization, provider, _revitRepository);
+ providerVm.RoundSymbol.SelectedItem =
+ providerVm.RoundSymbol.AvailableItems.FirstOrDefault(s =>
+ config?.RoundConnector?.Equals(s.Symbol) ?? false);
+ return providerVm;
+ }
+
+ private DuctsProviderViewModel CreateDuctsProvider(DuctsProvider provider, RevitSettings config) {
+ var providerVm = new DuctsProviderViewModel(_localization, provider, _revitRepository);
+ providerVm.RoundSymbol.SelectedItem =
+ providerVm.RoundSymbol.AvailableItems
+ .FirstOrDefault(s => config?.RoundConnector?.Equals(s.Symbol) ?? false);
+ providerVm.RectangleSymbol.SelectedItem =
+ providerVm.RectangleSymbol.AvailableItems.FirstOrDefault(s =>
+ config?.RectangleConnector?.Equals(s.Symbol) ?? false);
+ return providerVm;
+ }
+
+ private void CheckSyncNecessity(ICollection elements) {
+ if(_revitRepository.IsSyncRequired(elements)) {
+ _messageBoxService.Show(
+ _localization.GetLocalizedString("MainWindow.SyncRequired"),
+ _localization.GetLocalizedString("MainWindow.Title"),
+ MessageBoxButton.OK,
+ MessageBoxImage.Warning);
+ throw new OperationCanceledException();
+ }
+ }
+
+ private ICollection GetSplittableElements(ICollection selectedLevels) {
+ return ElementsProvider.Provider.GetElements(SelectionMode.Mode)
+ .Where(e => e.CanBeSplitted(selectedLevels))
+ .ToArray();
+ }
+
+ private ICollection GetSelectedLevels() {
+ return Levels
+ .Where(l => l.IsSelected)
+ .Select(l => l.Level)
+ .ToArray();
+ }
+
+ private void AcceptView() {
+ SaveConfig();
+ var selectedLevels = GetSelectedLevels();
+ var settings = ElementsProvider.GetSplitSettings(selectedLevels);
+ var splittable = GetSplittableElements(selectedLevels);
+
+ CheckSyncNecessity(splittable);
+
+ if(splittable.Count == 0) {
+ return;
+ }
+
+ using(var dialogService = ProgressFactory.CreateDialog()) {
+ dialogService.MaxValue = splittable.Count;
+ var progress = dialogService.CreateProgress();
+ var ct = dialogService.CreateCancellationToken();
+ int i = 0;
+ dialogService.Show();
+ using(var t = _revitRepository.Document.StartTransaction(
+ _localization.GetLocalizedString("MainWindow.TransactionName"))) {
+ foreach(var item in splittable) {
+ ct.ThrowIfCancellationRequested();
+ progress.Report(++i);
+ using(var subT = new SubTransaction(_revitRepository.Document)) {
+ subT.Start();
+ try {
+ var result = item.Split(settings);
+ result.UpdateSegments();
+ subT.Commit();
+ } catch(CannotGetConnectorSymbolException) {
+ _errorsService.AddError(item.Element, "Error.InsufficientSpace");
+ subT.RollBack();
+ } catch(CannotCreateConnectorException) {
+ _errorsService.AddError(item.Element, "Error.CannotCreateConnector");
+ subT.RollBack();
+ }
+ }
+ }
+
+ t.Commit();
+ }
+ }
+
+ ShowErrors();
+ }
+
+ private bool CanAcceptView() {
+ if(ElementsProvider is null) {
+ ErrorText = _localization.GetLocalizedString("MainWindow.Validation.NoProvider");
+ return false;
+ }
+ if(SelectionMode is null) {
+ ErrorText = _localization.GetLocalizedString("MainWindow.Validation.NoSelectionMode");
+ return false;
+ }
+
+ string providerError = ElementsProvider.GetErrorText();
+ if(!string.IsNullOrWhiteSpace(providerError)) {
+ ErrorText = providerError;
+ return false;
+ }
+ if(!Levels.Any(l => l.IsSelected)) {
+ ErrorText = _localization.GetLocalizedString("MainWindow.Validation.NoLevels");
+ return false;
+ }
+ ErrorText = null;
+ return true;
+ }
+
+ private void ShowErrors() {
+ if(ShowPlacingErrors && _errorsService.ContainsErrors()) {
+ _errorsWindowService.ShowErrorsWindow();
+ }
+ }
+
+ private void SaveConfig() {
+ var setting = _pluginConfig.GetSettings(_revitRepository.Document)
+ ?? _pluginConfig.AddSettings(_revitRepository.Document);
+
+ setting.SelectedMepClass = ElementsProvider?.MepClass ?? RevitSettings.DefaultMepClass;
+ setting.SelectedMode = _selectionMode?.Mode ?? RevitSettings.DefaultSelectionMode;
+ setting.UncheckedLevelNames = Levels
+ .Where(l => !l.IsSelected)
+ .Select(l => l.Name)
+ .ToList();
+ setting.ShowSplitErrors = ShowPlacingErrors;
+
+ if(ElementsProvider is PipesProviderViewModel pipes) {
+ setting.RoundConnector = new ConnectorConfig() {
+ SymbolName = pipes.RoundSymbol.SelectedItem?.Symbol.Name,
+ FamilyName = pipes.RoundSymbol.SelectedItem?.Symbol.FamilyName
+ };
+ setting.RectangleConnector = null;
+ } else if(ElementsProvider is DuctsProviderViewModel ducts) {
+ setting.RoundConnector = new ConnectorConfig() {
+ SymbolName = ducts.RoundSymbol.SelectedItem?.Symbol.Name,
+ FamilyName = ducts.RoundSymbol.SelectedItem?.Symbol.FamilyName
+ };
+ setting.RectangleConnector = new ConnectorConfig() {
+ SymbolName = ducts.RectangleSymbol.SelectedItem?.Symbol.Name,
+ FamilyName = ducts.RectangleSymbol.SelectedItem?.Symbol.FamilyName
+ };
+ }
+
+ _pluginConfig.SaveProjectConfig();
+ }
+
+ private void InitializeElementsProviders(RevitSettings config) {
+ foreach(var p in _providers) {
+ ElementsProviderViewModel vm = p switch {
+ PipesProvider pp => CreatePipesProvider(pp, config),
+ DuctsProvider dp => CreateDuctsProvider(dp, config),
+ _ => throw new InvalidOperationException()
+ };
+ AvailableElementsProviders.Add(vm);
+ }
+
+ ElementsProvider = AvailableElementsProviders
+ .FirstOrDefault(p => config != null && p.MepClass == config.SelectedMepClass)
+ ?? AvailableElementsProviders.First();
+ }
+
+ private void InitializeLevels(RevitSettings config) {
+ var levels = _revitRepository.GetLevels().OrderByDescending(l => l.Elevation);
+ foreach(var level in levels) {
+ var vm = new LevelViewModel(level, _localization);
+ vm.IsSelected = config is null
+ || !config.UncheckedLevelNames.Contains(level.Name, StringComparer.OrdinalIgnoreCase);
+ vm.PropertyChanged += OnLevelPropertyChanged;
+ Levels.Add(vm);
+ }
+
+ _selectAllLevels = Levels.All(l => l.IsSelected);
+ OnPropertyChanged(nameof(SelectAllLevels));
+ }
+
+ private void InitializeSelectionModes(RevitSettings config) {
+ bool hasSelectedElements = _revitRepository.HasSelectedElements();
+ foreach(var mode in Enum.GetValues(typeof(SelectionMode)).OfType()) {
+ var modeVm = new SelectionModeViewModel(_localization, mode);
+ if(mode == RevitSplitMepCurve.Models.Enums.SelectionMode.SelectedElements
+ && !hasSelectedElements) {
+ continue;
+ }
+
+ AvailableSelectionModes.Add(modeVm);
+ }
+
+ SelectionMode = hasSelectedElements
+ ? AvailableSelectionModes.First(m =>
+ m.Mode == RevitSplitMepCurve.Models.Enums.SelectionMode.SelectedElements)
+ : (AvailableSelectionModes.FirstOrDefault(m => config != null && m.Mode == config.SelectedMode)
+ ?? AvailableSelectionModes.First());
+ }
+
+ private void OnLevelPropertyChanged(object sender, PropertyChangedEventArgs e) {
+ if(e.PropertyName != nameof(LevelViewModel.IsSelected)) {
+ return;
+ }
+ bool allSelected = Levels.All(l => l.IsSelected);
+ if(_selectAllLevels != allSelected) {
+ _selectAllLevels = allSelected;
+ OnPropertyChanged(nameof(SelectAllLevels));
+ }
+ }
+
+ private void LoadConfig() {
+ var config = _pluginConfig.GetSettings(_revitRepository.Document);
+
+ InitializeLevels(config);
+ InitializeSelectionModes(config);
+ InitializeElementsProviders(config);
+
+ ShowPlacingErrors = config?.ShowSplitErrors ?? true;
+ }
+}
diff --git a/src/RevitSplitMepCurve/ViewModels/Providers/DuctsProviderViewModel.cs b/src/RevitSplitMepCurve/ViewModels/Providers/DuctsProviderViewModel.cs
new file mode 100644
index 000000000..c9d5a7a75
--- /dev/null
+++ b/src/RevitSplitMepCurve/ViewModels/Providers/DuctsProviderViewModel.cs
@@ -0,0 +1,65 @@
+using System.Collections.Generic;
+using System.Linq;
+using System;
+
+using Autodesk.Revit.DB;
+
+using dosymep.SimpleServices;
+
+using RevitSplitMepCurve.Models;
+using RevitSplitMepCurve.Models.Enums;
+using RevitSplitMepCurve.Models.Settings;
+using RevitSplitMepCurve.Services.Providers;
+using RevitSplitMepCurve.ViewModels.Symbols;
+
+namespace RevitSplitMepCurve.ViewModels.Providers;
+
+internal class DuctsProviderViewModel : ElementsProviderViewModel {
+ public DuctsProviderViewModel(
+ ILocalizationService localization,
+ DuctsProvider provider,
+ RevitRepository revitRepository) : base(localization, provider) {
+ var roundSymbols = revitRepository
+ .GetConnectorSymbols(BuiltInCategory.OST_DuctFitting, ConnectorProfileType.Round)
+ .Select(s => new FamilySymbolViewModel(s))
+ .ToArray();
+ var rectangleSymbols = revitRepository
+ .GetConnectorSymbols(BuiltInCategory.OST_DuctFitting, ConnectorProfileType.Rectangular)
+ .Select(s => new FamilySymbolViewModel(s))
+ .ToArray();
+ RoundSymbol = new SelectableFamilySymbolViewModel(
+ localization.GetLocalizedString("MainWindow.ConnectorRound"),
+ roundSymbols);
+ RectangleSymbol = new SelectableFamilySymbolViewModel(
+ localization.GetLocalizedString("MainWindow.ConnectorRectangle"),
+ rectangleSymbols);
+ Name = _localization.GetLocalizedString(
+ $"{nameof(RevitSplitMepCurve.Models.Enums.MepClass)}.{provider.MepClass}");
+ }
+
+ public override string Name { get; }
+
+ public override MepClass MepClass => MepClass.Ducts;
+
+ public SelectableFamilySymbolViewModel RoundSymbol { get; }
+
+ public SelectableFamilySymbolViewModel RectangleSymbol { get; }
+
+ public override string GetErrorText() {
+ if(RoundSymbol.SelectedItem is null || RectangleSymbol.SelectedItem is null) {
+ return _localization.GetLocalizedString("MainWindow.Validation.NoConnector");
+ }
+ return null;
+ }
+
+ public override ISplitSettings GetSplitSettings(ICollection levels) {
+ if(RoundSymbol.SelectedItem is null
+ || RectangleSymbol.SelectedItem is null) {
+ throw new InvalidOperationException();
+ }
+ return new SplitSettings(
+ RoundSymbol.SelectedItem.Symbol,
+ RectangleSymbol.SelectedItem.Symbol,
+ levels);
+ }
+}
diff --git a/src/RevitSplitMepCurve/ViewModels/Providers/ElementsProviderViewModel.cs b/src/RevitSplitMepCurve/ViewModels/Providers/ElementsProviderViewModel.cs
new file mode 100644
index 000000000..bd881cf0b
--- /dev/null
+++ b/src/RevitSplitMepCurve/ViewModels/Providers/ElementsProviderViewModel.cs
@@ -0,0 +1,34 @@
+using System;
+using System.Collections.Generic;
+
+using Autodesk.Revit.DB;
+
+using dosymep.SimpleServices;
+using dosymep.WPF.ViewModels;
+
+using RevitSplitMepCurve.Models.Enums;
+using RevitSplitMepCurve.Models.Settings;
+using RevitSplitMepCurve.Services.Providers;
+
+namespace RevitSplitMepCurve.ViewModels.Providers;
+
+internal abstract class ElementsProviderViewModel : BaseViewModel {
+ protected readonly ILocalizationService _localization;
+
+ protected ElementsProviderViewModel(ILocalizationService localization, IElementsProvider provider) {
+ _localization = localization ?? throw new ArgumentNullException(nameof(localization));
+ Provider = provider ?? throw new ArgumentNullException(nameof(provider));
+ }
+
+ public abstract string Name { get; }
+
+ public abstract MepClass MepClass { get; }
+
+ public IElementsProvider Provider { get; }
+
+ /// Текст ошибки валидации. Пусто если всё ок.
+ public abstract string GetErrorText();
+
+ /// Настройки разделения для выбранных уровней и текущих соединителей.
+ public abstract ISplitSettings GetSplitSettings(ICollection levels);
+}
diff --git a/src/RevitSplitMepCurve/ViewModels/Providers/PipesProviderViewModel.cs b/src/RevitSplitMepCurve/ViewModels/Providers/PipesProviderViewModel.cs
new file mode 100644
index 000000000..deb20bf35
--- /dev/null
+++ b/src/RevitSplitMepCurve/ViewModels/Providers/PipesProviderViewModel.cs
@@ -0,0 +1,52 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+using Autodesk.Revit.DB;
+
+using dosymep.SimpleServices;
+
+using RevitSplitMepCurve.Models;
+using RevitSplitMepCurve.Models.Enums;
+using RevitSplitMepCurve.Models.Settings;
+using RevitSplitMepCurve.Services.Providers;
+using RevitSplitMepCurve.ViewModels.Symbols;
+
+namespace RevitSplitMepCurve.ViewModels.Providers;
+
+internal class PipesProviderViewModel : ElementsProviderViewModel {
+ public PipesProviderViewModel(
+ ILocalizationService localization,
+ PipesProvider provider,
+ RevitRepository revitRepository) : base(localization, provider) {
+ var symbols = revitRepository
+ .GetConnectorSymbols(BuiltInCategory.OST_PipeFitting)
+ .Select(s => new FamilySymbolViewModel(s))
+ .ToArray();
+ RoundSymbol = new SelectableFamilySymbolViewModel(
+ localization.GetLocalizedString("MainWindow.ConnectorRound"), symbols);
+ Name = _localization.GetLocalizedString(
+ $"{nameof(RevitSplitMepCurve.Models.Enums.MepClass)}.{provider.MepClass}");
+ }
+
+ public override string Name { get; }
+
+ public override MepClass MepClass => MepClass.Pipes;
+
+ public SelectableFamilySymbolViewModel RoundSymbol { get; }
+
+ public override string GetErrorText() {
+ if(RoundSymbol.SelectedItem is null) {
+ return _localization.GetLocalizedString("MainWindow.Validation.NoConnector");
+ }
+ return null;
+ }
+
+ public override ISplitSettings GetSplitSettings(ICollection levels) {
+ if(RoundSymbol.SelectedItem is null) {
+ throw new InvalidOperationException();
+ }
+
+ return new SplitSettings(RoundSymbol.SelectedItem.Symbol, null, levels);
+ }
+}
diff --git a/src/RevitSplitMepCurve/ViewModels/SelectionModeViewModel.cs b/src/RevitSplitMepCurve/ViewModels/SelectionModeViewModel.cs
new file mode 100644
index 000000000..09a824503
--- /dev/null
+++ b/src/RevitSplitMepCurve/ViewModels/SelectionModeViewModel.cs
@@ -0,0 +1,36 @@
+using System;
+using System.Collections.Generic;
+
+using dosymep.SimpleServices;
+using dosymep.WPF.ViewModels;
+
+using RevitSplitMepCurve.Models.Enums;
+
+namespace RevitSplitMepCurve.ViewModels;
+
+internal class SelectionModeViewModel : BaseViewModel, IEquatable {
+ public SelectionModeViewModel(ILocalizationService localization, SelectionMode mode) {
+ if(localization == null) {
+ throw new ArgumentNullException(nameof(localization));
+ }
+
+ Mode = mode;
+ Name = localization.GetLocalizedString($"{nameof(SelectionMode)}.{mode}");
+ }
+
+ public string Name { get; }
+
+ public SelectionMode Mode { get; }
+
+ public bool Equals(SelectionModeViewModel other) {
+ return other is not null && Mode == other.Mode;
+ }
+
+ public override bool Equals(object obj) {
+ return Equals(obj as SelectionModeViewModel);
+ }
+
+ public override int GetHashCode() {
+ return EqualityComparer.Default.GetHashCode(Mode);
+ }
+}
diff --git a/src/RevitSplitMepCurve/ViewModels/Symbols/FamilySymbolViewModel.cs b/src/RevitSplitMepCurve/ViewModels/Symbols/FamilySymbolViewModel.cs
new file mode 100644
index 000000000..1e7d27ddf
--- /dev/null
+++ b/src/RevitSplitMepCurve/ViewModels/Symbols/FamilySymbolViewModel.cs
@@ -0,0 +1,39 @@
+using System;
+using System.Collections.Generic;
+
+using Autodesk.Revit.DB;
+
+using dosymep.WPF.ViewModels;
+
+namespace RevitSplitMepCurve.ViewModels.Symbols;
+
+internal class FamilySymbolViewModel : BaseViewModel, IEquatable {
+ public FamilySymbolViewModel(FamilySymbol symbol) {
+ Symbol = symbol ?? throw new ArgumentNullException(nameof(symbol));
+ Name = $"{symbol.FamilyName} : {symbol.Name}";
+ }
+
+ public string Name { get; }
+
+ public FamilySymbol Symbol { get; }
+
+ public bool Equals(FamilySymbolViewModel other) {
+ if(other is null) {
+ return false;
+ }
+
+ if(ReferenceEquals(this, other)) {
+ return true;
+ }
+
+ return Symbol.Id == other.Symbol.Id;
+ }
+
+ public override bool Equals(object obj) {
+ return Equals(obj as FamilySymbolViewModel);
+ }
+
+ public override int GetHashCode() {
+ return EqualityComparer.Default.GetHashCode(Symbol.Id);
+ }
+}
diff --git a/src/RevitSplitMepCurve/ViewModels/Symbols/SelectableFamilySymbolViewModel.cs b/src/RevitSplitMepCurve/ViewModels/Symbols/SelectableFamilySymbolViewModel.cs
new file mode 100644
index 000000000..fa84d0198
--- /dev/null
+++ b/src/RevitSplitMepCurve/ViewModels/Symbols/SelectableFamilySymbolViewModel.cs
@@ -0,0 +1,56 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.ComponentModel;
+using System.Linq;
+using System.Windows.Data;
+
+using dosymep.WPF.ViewModels;
+
+namespace RevitSplitMepCurve.ViewModels.Symbols;
+
+internal class SelectableFamilySymbolViewModel : BaseViewModel {
+ private FamilySymbolViewModel _selectedItem;
+ private string _searchText;
+
+ public SelectableFamilySymbolViewModel(string label, ICollection availableItems) {
+ Label = label ?? throw new ArgumentNullException(nameof(label));
+ AvailableItems = [.. availableItems.OrderBy(i => i.Name)];
+ FilteredItems = new CollectionViewSource() { Source = AvailableItems };
+ FilteredItems.Filter += SymbolsFilterHandler;
+ PropertyChanged += OnPropertyChanged;
+ }
+
+ public string Label { get; }
+
+ public FamilySymbolViewModel SelectedItem {
+ get => _selectedItem;
+ set => RaiseAndSetIfChanged(ref _selectedItem, value);
+ }
+
+ public string SearchText {
+ get => _searchText;
+ set => RaiseAndSetIfChanged(ref _searchText, value);
+ }
+
+ public ObservableCollection AvailableItems { get; }
+
+ public CollectionViewSource FilteredItems { get; }
+
+ private void SymbolsFilterHandler(object sender, FilterEventArgs e) {
+ if(e.Item is FamilySymbolViewModel symbol) {
+ if(!string.IsNullOrWhiteSpace(SearchText)) {
+ e.Accepted = symbol.Name.IndexOf(SearchText, StringComparison.CurrentCultureIgnoreCase) >= 0;
+ return;
+ }
+
+ e.Accepted = true;
+ }
+ }
+
+ private void OnPropertyChanged(object sender, PropertyChangedEventArgs e) {
+ if(e.PropertyName == nameof(SearchText)) {
+ FilteredItems?.View.Refresh();
+ }
+ }
+}
diff --git a/src/RevitSplitMepCurve/Views/ErrorsWindow.xaml b/src/RevitSplitMepCurve/Views/ErrorsWindow.xaml
new file mode 100644
index 000000000..8d8719be1
--- /dev/null
+++ b/src/RevitSplitMepCurve/Views/ErrorsWindow.xaml
@@ -0,0 +1,80 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/RevitSplitMepCurve/Views/ErrorsWindow.xaml.cs b/src/RevitSplitMepCurve/Views/ErrorsWindow.xaml.cs
new file mode 100644
index 000000000..2d0404b77
--- /dev/null
+++ b/src/RevitSplitMepCurve/Views/ErrorsWindow.xaml.cs
@@ -0,0 +1,25 @@
+using dosymep.SimpleServices;
+
+namespace RevitSplitMepCurve.Views;
+
+public partial class ErrorsWindow {
+ public ErrorsWindow(
+ ILoggerService loggerService,
+ ISerializationService serializationService,
+ ILanguageService languageService,
+ ILocalizationService localizationService,
+ IUIThemeService uiThemeService,
+ IUIThemeUpdaterService themeUpdaterService)
+ : base(loggerService,
+ serializationService,
+ languageService,
+ localizationService,
+ uiThemeService,
+ themeUpdaterService) {
+ InitializeComponent();
+ }
+
+ public override string PluginName => nameof(RevitSplitMepCurve);
+
+ public override string ProjectConfigName => nameof(ErrorsWindow);
+}
diff --git a/src/RevitSplitMepCurve/Views/MainWindow.xaml b/src/RevitSplitMepCurve/Views/MainWindow.xaml
new file mode 100644
index 000000000..45c233a7a
--- /dev/null
+++ b/src/RevitSplitMepCurve/Views/MainWindow.xaml
@@ -0,0 +1,205 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/RevitSplitMepCurve/Views/MainWindow.xaml.cs b/src/RevitSplitMepCurve/Views/MainWindow.xaml.cs
new file mode 100644
index 000000000..69a0ab382
--- /dev/null
+++ b/src/RevitSplitMepCurve/Views/MainWindow.xaml.cs
@@ -0,0 +1,36 @@
+using System.Windows;
+
+using dosymep.SimpleServices;
+
+namespace RevitSplitMepCurve.Views;
+
+public partial class MainWindow {
+ public MainWindow(
+ ILoggerService loggerService,
+ ISerializationService serializationService,
+ ILanguageService languageService,
+ ILocalizationService localizationService,
+ IUIThemeService uiThemeService,
+ IUIThemeUpdaterService themeUpdaterService)
+ : base(
+ loggerService,
+ serializationService,
+ languageService,
+ localizationService,
+ uiThemeService,
+ themeUpdaterService) {
+ InitializeComponent();
+ }
+
+ public override string PluginName => nameof(RevitSplitMepCurve);
+
+ public override string ProjectConfigName => nameof(MainWindow);
+
+ private void ButtonOk_Click(object sender, RoutedEventArgs e) {
+ DialogResult = true;
+ }
+
+ private void ButtonCancel_Click(object sender, RoutedEventArgs e) {
+ DialogResult = false;
+ }
+}
diff --git a/src/RevitSplitMepCurve/Views/Templates/ProvidersTemplates.xaml b/src/RevitSplitMepCurve/Views/Templates/ProvidersTemplates.xaml
new file mode 100644
index 000000000..9f2528ae3
--- /dev/null
+++ b/src/RevitSplitMepCurve/Views/Templates/ProvidersTemplates.xaml
@@ -0,0 +1,102 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/RevitSplitMepCurve/assets/Localization/Language.en-US.xaml b/src/RevitSplitMepCurve/assets/Localization/Language.en-US.xaml
new file mode 100644
index 000000000..2d28aa880
--- /dev/null
+++ b/src/RevitSplitMepCurve/assets/Localization/Language.en-US.xaml
@@ -0,0 +1,74 @@
+
+
+ Selected elements
+ Visible in view
+ Entire document
+
+ Pipes
+ Ducts
+
+ Split by Construction Level
+ Category
+ Selection mode
+ Levels to split
+ {0} ({1:+0;-0;0} mm)
+ Level elevations relative to project base point
+ Select all
+ Round connector
+ Rectangular connector
+ Show errors
+ OK
+ Cancel
+ Split by levels
+ Some elements are busy or outdated. Synchronize and try again.
+ Select a category
+ Select a selection mode
+ Select a connector
+ Select at least one level
+
+
+Levels at the same elevation:
+{0}
+
+ Insufficient space for connector
+ Element split error
+ Failed to get connector type
+ Failed to create one of the connectors
+
+ Split Report
+ Element
+
diff --git a/src/RevitSplitMepCurve/assets/Localization/Language.ru-RU.xaml b/src/RevitSplitMepCurve/assets/Localization/Language.ru-RU.xaml
new file mode 100644
index 000000000..e7938244f
--- /dev/null
+++ b/src/RevitSplitMepCurve/assets/Localization/Language.ru-RU.xaml
@@ -0,0 +1,74 @@
+
+
+ Выбранные элементы
+ Видимые на виде
+ Весь документ
+
+ Трубопроводы
+ Воздуховоды
+
+ Разделить по уровню СМР
+ Категория
+ Режим выбора
+ Уровни к делению
+ {0} ({1:+0;-0;0} мм)
+ Отметки уровней относительно базовой точки проекта
+ Выбрать все
+ Круглый соединитель
+ Прямоугольный соединитель
+ Показывать ошибки
+ ОК
+ Отмена
+ Разделение по уровням
+ Некоторые элементы заняты или устарели. Синхронизируйтесь и повторите.
+ Выберите категорию
+ Выберите режим отбора элементов
+ Выберите соединитель
+ Отметьте хотя бы один уровень
+
+
+Уровни на одной отметке:
+{0}
+
+ Недостаточно места для соединителя
+ Ошибка деления элемента
+ Не удалось получить типоразмер соединителя
+ Не удалось создать один из соединителей
+
+ Отчёт о разделении
+ Элемент
+
diff --git a/src/RevitSplitMepCurve/assets/Styles/CustomStyles.xaml b/src/RevitSplitMepCurve/assets/Styles/CustomStyles.xaml
new file mode 100644
index 000000000..6744943e4
--- /dev/null
+++ b/src/RevitSplitMepCurve/assets/Styles/CustomStyles.xaml
@@ -0,0 +1,154 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file