/
docNemo
/
clothes-graph
Обзор
Документация
Войти
/
docNemo
/
clothes-graph
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
main
src/ClothesGraph.App/ViewModels/SeamsViewModel.cs
489 строк
15 KB
docNemo
Запрос на слияние 'interface-to-spec' (
#1
) из interface-to-spec в main
09 авг 2026, 20:28
Верифицирован
09 авг 2026, 20:28
1bbc436
Код
Авторство
О чём код?
using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Globalization; using System.Linq; using ClothesGraph.Geometry; using ClothesGraph.Model; using ClothesGraph.Model.Formulas; using ClothesGraph.Model.Grading; using ClothesGraph.Model.Pieces; using ClothesGraph.Model.Seams; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; namespace ClothesGraph.App.ViewModels; /// <summary> /// Объявленный шов: его параметры и итог сверки. /// </summary> /// <remarks> /// Допуск и посадка живут в модели с самого начала, но задать их можно было /// только правкой файла. Окат рукава, ради которого посадка и заведена, /// настроить из программы было нечем — и он вечно числился проблемным. /// </remarks> public sealed partial class SeamRowViewModel : ObservableObject { private readonly SeamsViewModel _owner; private bool _loading = true; [ObservableProperty] private string _name = string.Empty; [ObservableProperty] private string _tolerance = string.Empty; [ObservableProperty] private string _ease = string.Empty; [ObservableProperty] private string _status = string.Empty; [ObservableProperty] private bool _isSound = true; internal SeamRowViewModel(SeamsViewModel owner, Seam seam) { _owner = owner; Seam = seam; } internal Seam Seam { get; } /// <summary>Расхождение обязано бросаться в глаза.</summary> public string Colour => IsSound ? "#9BD5A8" : "#FF9B9B"; public string Background => IsSound ? "#1A2A20" : "#2A1F22"; /// <summary>Заполняет строку из модели, не записывая её обратно.</summary> internal void Load(string tolerance, string ease, string status, bool isSound) { _loading = true; try { Name = Seam.Name; Tolerance = tolerance; Ease = ease; Status = status; IsSound = isSound; OnPropertyChanged(nameof(Colour)); OnPropertyChanged(nameof(Background)); } finally { _loading = false; } } partial void OnNameChanged(string value) { if (_loading || string.IsNullOrWhiteSpace(value)) return; Seam.Name = value.Trim(); _owner.NotifyEdited(); } partial void OnToleranceChanged(string value) { if (_loading) return; _owner.SetTolerance(this, value); } partial void OnEaseChanged(string value) { if (_loading) return; _owner.SetEase(this, value); } } /// <summary>Незашитый участок контура.</summary> public sealed record UnsewnRow(string Piece, int Segment) { /// <summary> /// Номер участка от единицы — как в списке контура детали. /// </summary> /// <remarks> /// Нумерация во всех местах интерфейса одна: перечень незашитых участков, /// список контура и объявление шва обязаны называть один и тот же участок /// одинаково, иначе пользователь сошьёт соседний. /// </remarks> public string Text => $"{Piece}: участок {Segment + 1}"; } /// <summary> /// Швы изделия и сверка их длин. /// </summary> /// <remarks> /// Самая полезная проверка до раскроя: сшиваемые участки обязаны сходиться, /// и узнать об этом нужно на экране, а не на ткани. /// </remarks> public sealed partial class SeamsViewModel : ObservableObject { private readonly PatternDocument _document; private readonly SeamChecker _checker; /// <summary> /// Сверка уже идёт. /// </summary> /// <remarks> /// Пересборка списка деталей меняет выбранную деталь, а её смена сама /// просит пересверить — без защиты обход пошёл бы по кругу. /// </remarks> private bool _refreshing; [ObservableProperty] private string _summary = string.Empty; [ObservableProperty] private bool _checkWholeRange; [ObservableProperty] private string? _error; public SeamsViewModel(PatternDocument document, GradingService? grading = null) { _document = document; _checker = new SeamChecker(document, grading); Seams = []; Unsewn = []; Pieces = [.. document.Pieces]; FirstMap = new PieceSeamMapViewModel(index => FirstSegment = index + 1); SecondMap = new PieceSeamMapViewModel(index => SecondSegment = index + 1); Refresh(); } /// <summary>Карта участков первой детали: выбор указанием.</summary> public PieceSeamMapViewModel FirstMap { get; } public PieceSeamMapViewModel SecondMap { get; } /// <summary>Описание выбранных участков для панели объявления шва.</summary> public string FirstChoice => FirstMap.Describe(); public string SecondChoice => SecondMap.Describe(); /// <summary>Объявленные швы с их параметрами и итогом сверки.</summary> public ObservableCollection<SeamRowViewModel> Seams { get; } public ObservableCollection<UnsewnRow> Unsewn { get; } public ObservableCollection<PatternPiece> Pieces { get; } public LengthUnit DisplayUnit => _document.DisplayUnit; partial void OnCheckWholeRangeChanged(bool value) => Refresh(); /// <summary>Правка параметров шва: пересверить и отметить документ.</summary> internal void NotifyEdited() { _document.MarkModified(); Refresh(); } internal void SetTolerance(SeamRowViewModel row, string text) { if (!Length.TryParse(text, DisplayUnit, CultureInfo.CurrentCulture, out var tolerance) || tolerance < Length.Zero) { Error = "Допуск задаётся неотрицательной длиной"; return; } row.Seam.Tolerance = tolerance; Error = null; NotifyEdited(); } /// <summary> /// Задаёт запланированную посадку шва. /// </summary> /// <remarks> /// Пустое поле означает отсутствие посадки, а не нулевую формулу: шов без /// посадки и шов с посадкой в ноль различаются тем, что первый ничего о ней /// не утверждает. /// </remarks> internal void SetEase(SeamRowViewModel row, string text) { if (string.IsNullOrWhiteSpace(text)) { row.Seam.Ease = null; Error = null; NotifyEdited(); return; } try { row.Seam.Ease = _document.ParseFormula(text); Error = null; NotifyEdited(); } catch (FormulaException error) { Error = $"Посадка: {error.Message} (позиция {error.Position + 1})"; } } /// <summary>Снимает шов: его участки снова свободны для других швов.</summary> [RelayCommand] private void RemoveSeam(SeamRowViewModel? row) { if (row is null) return; _document.Seams.Remove(row.Seam.Id); _document.MarkModified(); Refresh(); } [RelayCommand] private void Refresh() { if (_refreshing) return; _refreshing = true; try { RefreshCore(); } finally { _refreshing = false; } } private void RefreshCore() { RebuildPieces(); RebuildSeams(); Unsewn.Clear(); if (_document.Pieces.Count == 0) { Summary = "В документе нет деталей"; return; } try { // Шов, сходящийся в базовом размере, может разойтись в крайних: // участки растут по разным формулам. var reports = CheckWholeRange ? _checker.CheckRange() : [_checker.Check()]; LoadRows(reports); foreach (var edge in reports[0].Unsewn) { var piece = _document.Pieces.FirstOrDefault(item => item.Id == edge.Piece); if (piece is not null) Unsewn.Add(new UnsewnRow(piece.Name, edge.SegmentIndex)); } RebuildMaps(reports); var problems = Seams.Count(row => !row.IsSound); Summary = _document.Seams.Count == 0 ? "Швы не объявлены" : problems == 0 ? $"Швов: {Seams.Count}, все сходятся" : $"Швов: {Seams.Count}, расходятся: {problems}"; } catch (Exception error) { Error = error.Message; Summary = "Не удалось сверить швы"; } } /// <summary>Раскладывает итоги сверки по строкам швов.</summary> private void LoadRows(IReadOnlyList<SeamReport> reports) { var checksBySeam = reports .SelectMany(report => report.Checks) .GroupBy(check => check.Seam) .ToDictionary(group => group.Key, group => group.ToArray()); foreach (var row in Seams) { var checks = checksBySeam.GetValueOrDefault(row.Seam.Id, []); var problems = checks.Where(check => !check.IsSound).ToArray(); var status = checks.Length == 0 ? "не сверялся" : problems.Length == 0 ? checks.Length == 1 ? checks[0].Describe(DisplayUnit) : $"сходится во всех размерах ({checks.Length})" : string.Join("\n", problems.Select(check => check.Describe(DisplayUnit))); row.Load( row.Seam.Tolerance.ToString(DisplayUnit, CultureInfo.CurrentCulture), row.Seam.Ease?.ToText(_document) ?? string.Empty, status, problems.Length == 0); } } /// <summary>Перерисовывает карты участков обеих выбранных деталей.</summary> private void RebuildMaps(IReadOnlyList<SeamReport> reports) { var problems = reports .SelectMany(report => report.Problems) .Select(check => check.Seam) .ToHashSet(); var (evaluation, values) = _document.Evaluate(); Load(FirstMap, FirstPiece); Load(SecondMap, SecondPiece); OnPropertyChanged(nameof(FirstChoice)); OnPropertyChanged(nameof(SecondChoice)); void Load(PieceSeamMapViewModel map, PatternPiece? piece) => map.Load( piece, piece is null ? null : PieceBuilder.Build(piece, evaluation, values, _document.FormulaUnit), _document.Seams, problems); } partial void OnFirstPieceChanged(PatternPiece? value) { FirstMap.SelectedSegment = null; Refresh(); } partial void OnSecondPieceChanged(PatternPiece? value) { SecondMap.SelectedSegment = null; Refresh(); } partial void OnFirstSegmentChanged(int value) { FirstMap.SelectedSegment = value - 1; OnPropertyChanged(nameof(FirstChoice)); } partial void OnSecondSegmentChanged(int value) { SecondMap.SelectedSegment = value - 1; OnPropertyChanged(nameof(SecondChoice)); } /// <summary>Пересобирает строки, если состав швов изменился.</summary> private void RebuildSeams() { var current = _document.Seams.Seams.ToArray(); if (Seams.Count == current.Length && Seams.Select(row => row.Seam).SequenceEqual(current)) return; Seams.Clear(); foreach (var seam in current) Seams.Add(new SeamRowViewModel(this, seam)); } /// <summary> /// Подхватывает состав деталей документа. /// </summary> /// <remarks> /// Детали создаются на своей вкладке, и без пересборки шов было бы не с чем /// объявлять: списки выбора остались бы с теми деталями, что были в момент /// открытия документа. /// </remarks> private void RebuildPieces() { if (Pieces.SequenceEqual(_document.Pieces)) return; var first = FirstPiece?.Id; var second = SecondPiece?.Id; Pieces.Clear(); foreach (var piece in _document.Pieces) Pieces.Add(piece); FirstPiece = Pieces.FirstOrDefault(piece => piece.Id == first); SecondPiece = Pieces.FirstOrDefault(piece => piece.Id == second); } /// <summary>Соединяет выбранные участки двух деталей.</summary> [RelayCommand] private void AddSeam() { if (FirstPiece is null || SecondPiece is null) { Error = "Выберите две детали"; return; } try { _document.Seams.Add(new Seam( SeamId.New(), string.IsNullOrWhiteSpace(SeamName) ? $"Шов {_document.Seams.Count + 1}" : SeamName.Trim(), new SeamEdge(FirstPiece.Id, FirstSegment - 1), new SeamEdge(SecondPiece.Id, SecondSegment - 1), ReverseSecond)); _document.MarkModified(); SeamName = string.Empty; Error = null; Refresh(); } catch (SeamEdgeOccupiedException occupied) { Error = occupied.Message; } catch (InvalidOperationException problem) { Error = problem.Message; } } [ObservableProperty] private PatternPiece? _firstPiece; /// <summary>Номер участка от единицы — как во всех прочих списках.</summary> [ObservableProperty] private int _firstSegment = 1; [ObservableProperty] private PatternPiece? _secondPiece; [ObservableProperty] private int _secondSegment = 1; [ObservableProperty] private bool _reverseSecond = true; [ObservableProperty] private string _seamName = string.Empty; }