/
docNemo
/
clothes-graph
Обзор
Документация
Войти
/
docNemo
/
clothes-graph
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
main
src/ClothesGraph.App/ViewModels/DraftViewModel.cs
1 388 строк
53 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.Drafting; using ClothesGraph.Model.Editing; using ClothesGraph.Model.Formulas; using ClothesGraph.Model.Grading; using ClothesGraph.Model.Underlay; using ClothesGraph.Rendering; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; namespace ClothesGraph.App.ViewModels; /// <summary> /// Числовой параметр построения, заданный формулой. /// </summary> /// <remarks> /// Построения задаются не одной формулой: у точки смещением их две — расстояние /// и угол, у свободной точки тоже две. Панель правки поэтому строится по /// параметрам выбранного построения, а не держит одно поле: раньше она правила /// расстояние у точек смещением и на все прочие построения отвечала отказом. /// </remarks> public sealed partial class FormulaFieldViewModel : ObservableObject { private readonly DraftViewModel _owner; private readonly Action<DraftNode, Formula> _assign; [ObservableProperty] private string _text; internal FormulaFieldViewModel( DraftViewModel owner, string label, string text, bool isLength, Action<DraftNode, Formula> assign) { _owner = owner; _assign = assign; _text = text; Label = label; IsLength = isLength; } public string Label { get; } /// <summary> /// Параметр измеряется длиной. /// </summary> /// <remarks> /// Подбор формулы от мерок имеет смысл только для длин: угол в градусах и /// доля длины линии мерками не выражаются, и предлагать для них «Ог/4» /// значило бы предлагать бессмыслицу. /// </remarks> public bool IsLength { get; } /// <summary>Записывает разобранную формулу в построение.</summary> internal void Assign(DraftNode node, Formula formula) => _assign(node, formula); /// <summary>Дописывает имя мерки или построения к формуле.</summary> internal void Append(string name) => Text = string.IsNullOrWhiteSpace(Text) ? name : Text.TrimEnd() + name; partial void OnTextChanged(string value) => _owner.ActiveFormula = this; } /// <summary>Ручная поправка к положению построения в одном размере.</summary> public sealed class AdjustmentRow( string size, NodeId node, string nodeName, Offset2 offset, LengthUnit unit) { public string Size { get; } = size; internal NodeId Node { get; } = node; public string Text { get; } = $"{size}: {nodeName} сдвинута на {offset.X.ToString(unit, CultureInfo.CurrentCulture)} " + $"по горизонтали и {offset.Y.ToString(unit, CultureInfo.CurrentCulture)} по вертикали"; } /// <summary>Формула, подобранная под снятую с образца величину.</summary> public sealed class FormulaSuggestionRow { internal FormulaSuggestionRow( FormulaFieldViewModel field, FormulaSuggestion suggestion, LengthUnit formulaUnit) { Field = field; Text = suggestion.Text; Description = suggestion.Describe(formulaUnit); } /// <summary>Поле, число в котором заменит эта формула.</summary> internal FormulaFieldViewModel Field { get; } public string Text { get; } /// <summary>Запись формулы вместе с её отклонением от искомой величины.</summary> public string Description { get; } } /// <summary> /// Чертёж: пересчёт, выбор построений, создание точек и правка формул. /// </summary> public sealed partial class DraftViewModel : ObservableObject { private readonly PatternDocument _document; [ObservableProperty] private NodeId? _selectedNode; [ObservableProperty] private NodeId? _hoveredNode; [ObservableProperty] private string _status = string.Empty; [ObservableProperty] private string? _editorError; [ObservableProperty] private string _newPointName = string.Empty; [ObservableProperty] private string _newPointDistance = "10"; [ObservableProperty] private string _newPointAngle = "0"; /// <summary> /// Поле, куда попадёт вставленное имя. /// </summary> /// <remarks> /// У построения бывает несколько формул, и вставлять имя нужно в то поле, /// с которым пользователь работает. Им считается последнее, где он правил /// текст: отслеживать перевод фокуса ради этого не потребовалось. /// </remarks> [ObservableProperty] private FormulaFieldViewModel? _activeFormula; /// <summary>Имя, выбранное в списке подстановки.</summary> [ObservableProperty] private string? _nameToInsert; /// <summary>Выбранное предложение подбора формулы.</summary> [ObservableProperty] private FormulaSuggestionRow? _selectedSuggestion; /// <summary> /// Размер, в котором чертёж показывается и правится. /// </summary> /// <remarks> /// Отдельно от базового намеренно. Базовый — свойство документа: в нём /// конструкция задана. Активный — состояние вида, и переключение его /// документ не меняет. Раньше посмотреть другой размер можно было либо /// наложением фоном, либо объявив его базовым, то есть изменив смысл /// документа ради того, чтобы взглянуть. /// </remarks> [ObservableProperty] private string _activeSize; private readonly GradingService _grading; public DraftViewModel(PatternDocument document, Func<Avalonia.Controls.Window?>? window = null) { _document = document; Underlays = new UnderlayViewModel(document, window ?? (() => null)); Underlays.Changed += (_, _) => UnderlaysChanged?.Invoke(this, EventArgs.Empty); _grading = new GradingService(document); // Чертёж открывается в базовом размере, но им не ограничен: активный // размер — это состояние вида, а не свойство документа. _activeSize = document.Sizes.BaseSize; AvailableSizes = [.. document.Sizes.Sizes]; Scene = new DraftScene(); Points = []; Failures = []; AvailableNames = []; OverlaidSizes = []; SelectedFormulas = []; FormulaSuggestions = []; Adjustments = []; foreach (var size in document.Sizes.Sizes) OverlaidSizes.Add(new SizeOverlayOption(this, size) { IsShown = Program.OverlayAllSizes }); RebuildAdjustments(); Recalculate(); } /// <summary>Размеры ряда с признаком показа поверх основной конструкции.</summary> public ObservableCollection<SizeOverlayOption> OverlaidSizes { get; } /// <summary>Размеры, доступные для показа и правки.</summary> public ObservableCollection<string> AvailableSizes { get; } /// <summary>Активный размер совпадает с базовым.</summary> public bool IsBaseSizeActive => string.Equals( ActiveSize, _document.Sizes.BaseSize, StringComparison.CurrentCultureIgnoreCase); /// <summary>Переключение активного размера: документ при этом не меняется.</summary> partial void OnActiveSizeChanged(string value) { OnPropertyChanged(nameof(IsBaseSizeActive)); RebuildFormulaFields(); Recalculate(); } /// <summary>Подложки под чертежом.</summary> public UnderlayViewModel Underlays { get; private set; } = null!; public PatternDocument Document => _document; public DraftScene Scene { get; } public ObservableCollection<DraftPointMarker> Points { get; } public ObservableCollection<DraftFailureMarker> Failures { get; } /// <summary>Имена мерок и построений — подсказка при наборе формул.</summary> public ObservableCollection<string> AvailableNames { get; } /// <summary>Числовые параметры выбранного построения.</summary> public ObservableCollection<FormulaFieldViewModel> SelectedFormulas { get; } /// <summary>У выбранного построения есть что править формулой.</summary> public bool HasFormulas => SelectedFormulas.Count > 0; /// <summary>Формулы, подобранные под текущее значение параметра.</summary> public ObservableCollection<FormulaSuggestionRow> FormulaSuggestions { get; } public event EventHandler? SceneChanged; /// <summary>Состав или вид подложек изменился.</summary> public event EventHandler? UnderlaysChanged; public bool HasFailures => Failures.Count > 0; /// <summary>Пересчитывает чертёж и пересобирает то, что выводится на экран.</summary> public void Recalculate() { var evaluation = _grading.Evaluate(ActiveSize); RebuildScene(evaluation); RebuildPoints(evaluation); RebuildFailures(evaluation); RebuildNames(); var where = IsBaseSizeActive ? $"размер {ActiveSize}, базовый" : $"размер {ActiveSize}"; Status = evaluation.IsComplete ? $"Построений: {_document.Draft.Count} · {where}" : $"Построений: {_document.Draft.Count}, " + $"неразрешимых: {evaluation.Failures.Count} · {where}"; OnPropertyChanged(nameof(HasFailures)); SceneChanged?.Invoke(this, EventArgs.Empty); } private void RebuildScene(DraftEvaluation evaluation) { Scene.Clear(); // Другие размеры кладутся первыми: они уходят под основную // конструкцию, а не закрывают её. foreach (var size in OverlaidSizes.Where(item => item.IsShown)) { // Активный размер уже рисуется как основной; накладывать его // на самого себя значило бы затемнять его вторым слоем. if (string.Equals(size.Size, ActiveSize, StringComparison.CurrentCultureIgnoreCase)) continue; var report = _grading.ReportFor(size.Size); if (!report.IsReady) continue; foreach (var (id, curve) in _grading.Evaluate(size.Size).Curves) Scene.Add(id, curve, LineRole.OtherSize); } foreach (var (id, curve) in evaluation.Curves) { var role = id == SelectedNode ? LineRole.Selected : id == HoveredNode ? LineRole.Highlighted : LineRole.Construction; Scene.Add(id, curve, role); } } private void RebuildPoints(DraftEvaluation evaluation) { Points.Clear(); foreach (var (id, position) in evaluation.Points) { var node = _document.Draft.Get(id); Points.Add(new DraftPointMarker( id, node.Name, position, IsSelected: id == SelectedNode, IsHovered: id == HoveredNode)); } } private void RebuildFailures(DraftEvaluation evaluation) { Failures.Clear(); foreach (var (id, failure) in evaluation.Failures.OrderBy(pair => pair.Value.NodeName)) Failures.Add(new DraftFailureMarker(id, failure.NodeName, failure.Reason)); } private void RebuildNames() { AvailableNames.Clear(); foreach (var name in _document.Measurements.Measurements .Select(measurement => measurement.DisplayName) .Concat(_document.Draft.Nodes.Select(node => node.Name)) .OrderBy(name => name)) { AvailableNames.Add(name); } } /// <summary> /// Прямоугольник, охватывающий все вычисленные точки чертежа. /// Пусто, если чертёж пуст. /// </summary> public (Point2 Min, Point2 Max)? ContentBounds() { if (Points.Count == 0) return null; var minX = long.MaxValue; var minY = long.MaxValue; var maxX = long.MinValue; var maxY = long.MinValue; foreach (var marker in Points) { minX = Math.Min(minX, marker.Position.X.Units); minY = Math.Min(minY, marker.Position.Y.Units); maxX = Math.Max(maxX, marker.Position.X.Units); maxY = Math.Max(maxY, marker.Position.Y.Units); } return (new Point2(Length.FromUnits(minX), Length.FromUnits(minY)), new Point2(Length.FromUnits(maxX), Length.FromUnits(maxY))); } /// <summary>Ближайшее построение к точке чертежа в пределах допуска.</summary> /// <remarks> /// Точки имеют приоритет над линиями: узел построения всегда лежит на /// какой-нибудь линии, и без приоритета попасть в него курсором было бы /// почти невозможно. /// </remarks> public NodeId? HitTest(Point2 world, Length tolerance) { var best = (NodeId?)null; var bestDistance = tolerance; foreach (var marker in Points) { var distance = marker.Position.DistanceTo(world); if (distance <= bestDistance) { bestDistance = distance; best = marker.Id; } } if (best is not null) return best; var bestLineDistance = tolerance; foreach (var line in Scene.Lines) { var distance = DistanceToCurve(line.Curve, world); if (distance <= bestLineDistance) { bestLineDistance = distance; best = (NodeId)line.Id; } } return best; } private static Length DistanceToCurve(ICurve curve, Point2 point) { // Кривая опрашивается в фиксированном числе точек: для попадания // курсором этого достаточно, а стоимость предсказуема. const int samples = 24; var best = long.MaxValue; var previous = curve.Start; for (var index = 1; index <= samples; index++) { var current = curve.PointAt(index / (double)samples); var distance = DistanceToSegment(previous, current, point); if (distance < best) best = distance; previous = current; } return Length.FromUnits(best); } private static long DistanceToSegment(Point2 a, Point2 b, Point2 point) { double ax = a.X.Units, ay = a.Y.Units; double bx = b.X.Units, by = b.Y.Units; double px = point.X.Units, py = point.Y.Units; var dx = bx - ax; var dy = by - ay; var lengthSquared = dx * dx + dy * dy; if (lengthSquared < 1e-6) return (long)Math.Sqrt((px - ax) * (px - ax) + (py - ay) * (py - ay)); var t = Math.Clamp(((px - ax) * dx + (py - ay) * dy) / lengthSquared, 0, 1); var nearestX = ax + dx * t; var nearestY = ay + dy * t; return (long)Math.Sqrt( (px - nearestX) * (px - nearestX) + (py - nearestY) * (py - nearestY)); } /// <summary> /// Ранее выбранное построение. /// </summary> /// <remarks> /// Построения, которым нужны два объекта — пересечение линий, — берут /// предыдущий выбор и текущий. Это избавляет от отдельного режима /// «выберите первую линию, теперь вторую». /// </remarks> private NodeId? _previousSelection; /// <summary> /// Выбранные точки в порядке выбора; хранятся последние четыре. /// </summary> /// <remarks> /// Кубическая кривая строится на четырёх точках, и указать их нужно по /// порядку. Повторы намеренно не отбрасываются: одна и та же точка законно /// служит и направляющей, и концом — так построена пройма в поставляемой /// конструкции. Промах исправляется продолжением выбора: лишняя точка /// вытесняется из буфера сама. /// </remarks> private readonly List<NodeId> _pickedPoints = []; private const int CurvePointCount = 4; /// <summary>Имена выбранных точек в порядке выбора — подсказка при построении кривой.</summary> public string PickedPointNames => string.Join(" → ", _pickedPoints .Where(id => _document.Draft.Contains(id)) .Select(id => _document.Draft.Get(id).Name)); partial void OnSelectedNodeChanged(NodeId? value) { EditorError = null; // Отложенное подтверждение относится к тому построению, на котором его // запросили. Уход к другому построению отменяет запрос: иначе возврат к // прежнему выбору удалил бы его вместе с зависимыми сразу, а // предупреждение к тому времени уже сошло бы с экрана. _pendingDeletionOf = null; if (value is { } id && _document.Draft.TryGet(id, out var node) && node is PointNode) { _pickedPoints.Add(id); if (_pickedPoints.Count > CurvePointCount) _pickedPoints.RemoveRange(0, _pickedPoints.Count - CurvePointCount); OnPropertyChanged(nameof(PickedPointNames)); } RebuildFormulaFields(); Recalculate(); } partial void OnSelectedNodeChanging(NodeId? value) { if (SelectedNode is { } previous && previous != value) _previousSelection = previous; } partial void OnHoveredNodeChanged(NodeId? value) => Recalculate(); /// <summary>Пересобирает поля правки под выбранное построение.</summary> private void RebuildFormulaFields() { SelectedFormulas.Clear(); ActiveFormula = null; if (SelectedNode is { } id && _document.Draft.TryGet(id, out var node) && node is not null) foreach (var field in FieldsOf(node)) SelectedFormulas.Add(field); OnPropertyChanged(nameof(HasFormulas)); } /// <summary> /// Числовые параметры построения, доступные правке формулой. /// </summary> /// <remarks> /// Пересечение двух линий и сами линии числовых параметров не имеют: /// их положение целиком определяется тем, на что они ссылаются. /// </remarks> private IEnumerable<FormulaFieldViewModel> FieldsOf(DraftNode node) => node switch { OffsetPoint offset => [ Field("Расстояние", offset.Distance, isLength: true, static (target, formula) => ((OffsetPoint)target).Distance = formula), Field("Угол, °", offset.AngleDegrees, isLength: false, static (target, formula) => ((OffsetPoint)target).AngleDegrees = formula) ], FreePoint free => [ Field("Координата x", free.X, isLength: true, static (target, formula) => ((FreePoint)target).X = formula), Field("Координата y", free.Y, isLength: true, static (target, formula) => ((FreePoint)target).Y = formula) ], PointOnCurve onCurve => [ Field( onCurve.Mode == PointOnCurveMode.Parameter ? "Доля длины линии" : "Расстояние вдоль линии", onCurve.Position, isLength: onCurve.Mode == PointOnCurveMode.Distance, static (target, formula) => ((PointOnCurve)target).Position = formula) ], _ => [] }; private FormulaFieldViewModel Field( string label, Formula current, bool isLength, Action<DraftNode, Formula> assign) => new(this, label, current.ToText(_document), isLength, assign); /// <summary>Применяет правку всех формул выбранного построения.</summary> [RelayCommand] private void ApplyFormula() { if (SelectedNode is not { } id || !_document.Draft.TryGet(id, out _)) return; if (SelectedFormulas.Count == 0) { EditorError = "У выбранного построения нет параметров, задаваемых формулой"; return; } // Сперва разбираются все формулы и только потом записываются: ошибка во // второй не должна оставить построение с применённой первой — это // состояние, которого пользователь не задавал. var parsed = new List<(FormulaFieldViewModel Field, Formula Formula)>(); foreach (var field in SelectedFormulas) { try { parsed.Add((field, _document.ParseFormula(field.Text))); } catch (FormulaException error) { // Прежние формулы остаются в силе: разбор не дошёл до модели. EditorError = $"{field.Label}: {error.Message} (позиция {error.Position + 1})"; return; } } try { _document.Undo.Do(new UpdateNodeAction( _document.Draft, id, "Правка формулы", target => { foreach (var (field, formula) in parsed) field.Assign(target, formula); })); EditorError = null; RebuildFormulaFields(); Recalculate(); } catch (DraftCycleException error) { EditorError = error.Message; } } /// <summary> /// Подбирает формулы от мерок под текущее значение параметра. /// </summary> /// <remarks> /// Обратная задача к обычному построению: величина снята с образца и уже /// стоит в чертеже числом, но числом она не градируется. Подбор существовал /// в модели с самого начала, а вызвать его из программы было нечем — и /// работа от образца обрывалась ровно там, где начинается польза. /// </remarks> [RelayCommand] private void SuggestFormula() { FormulaSuggestions.Clear(); var field = ActiveFormula is { IsLength: true } active ? active : SelectedFormulas.FirstOrDefault(item => item.IsLength); if (field is null) { EditorError = "Подбор формулы возможен для параметра, выраженного длиной"; return; } Length current; try { var (_, values) = _document.Evaluate(); current = Length.From( _document.ParseFormula(field.Text).Evaluate(values), _document.FormulaUnit); } catch (FormulaException error) { EditorError = $"{field.Label}: {error.Message} (позиция {error.Position + 1})"; return; } catch (FormulaEvaluationException error) { EditorError = $"{field.Label}: {error.Message}"; return; } var suggester = new FormulaSuggester(_document.Measurements, _document.FormulaUnit); foreach (var suggestion in suggester.Suggest(current)) FormulaSuggestions.Add(new FormulaSuggestionRow( field, suggestion, _document.FormulaUnit)); EditorError = FormulaSuggestions.Count == 0 ? "Формулы от заданных мерок, дающей это значение, не нашлось" : null; } /// <summary> /// Выбор предложения заменяет число формулой и сразу применяет правку. /// </summary> /// <remarks> /// Смысл подбора в том, чтобы построение начало градироваться; отдельное /// нажатие «Применить» после выбора ничего не решало бы, а забыть его /// значило бы остаться с прежним числом. /// </remarks> partial void OnSelectedSuggestionChanged(FormulaSuggestionRow? value) { if (value is null) return; var field = value.Field; var text = value.Text; SelectedSuggestion = null; FormulaSuggestions.Clear(); field.Text = text; ActiveFormula = field; ApplyFormula(); } /// <summary>Дописывает имя мерки или построения в правящееся поле.</summary> [RelayCommand] private void InsertName(string? name) { if (string.IsNullOrWhiteSpace(name)) return; var target = ActiveFormula ?? SelectedFormulas.FirstOrDefault(); if (target is null) { EditorError = "Выберите построение, у которого есть формула"; return; } target.Append(name); ActiveFormula = target; } /// <summary> /// Выбор имени в списке дописывает его в формулу и сбрасывает список. /// </summary> /// <remarks> /// Сброс нужен, чтобы одно и то же имя можно было вставить дважды подряд: /// без него повторный выбор того же пункта не считался бы изменением. /// </remarks> partial void OnNameToInsertChanged(string? value) { if (value is null) return; InsertName(value); NameToInsert = null; } /// <summary>Создаёт точку смещением от выбранной.</summary> [RelayCommand] private void AddOffsetPoint() { if (SelectedNode is not { } origin || _document.Draft.Get(origin) is not PointNode) { EditorError = "Сначала выберите точку, от которой откладывать"; return; } try { var name = string.IsNullOrWhiteSpace(NewPointName) ? NextName() : NewPointName.Trim(); if (_document.Draft.FindByName(name) is not null) { EditorError = $"Имя «{name}» уже занято"; return; } var node = new OffsetPoint( NodeId.New(), name, origin, _document.ParseFormula(NewPointDistance), _document.ParseFormula(NewPointAngle)); _document.Undo.Do(new AddNodeAction(_document.Draft, node)); NewPointName = string.Empty; EditorError = null; SelectedNode = node.Id; } catch (FormulaException error) { EditorError = $"{error.Message} (позиция {error.Position + 1})"; } } /// <summary>Создаёт точку на выбранной линии по расстоянию вдоль неё.</summary> [RelayCommand] private void AddPointOnCurve() { if (SelectedNode is not { } id || _document.Draft.Get(id) is not CurveNode) { EditorError = "Сначала выберите линию"; return; } try { var node = new PointOnCurve( NodeId.New(), NextName(), id, _document.ParseFormula(NewPointDistance), PointOnCurveMode.Distance); _document.Undo.Do(new AddNodeAction(_document.Draft, node)); EditorError = null; SelectedNode = node.Id; } catch (FormulaException error) { EditorError = $"{error.Message} (позиция {error.Position + 1})"; } } /// <summary>Создаёт точку пересечения выбранной линии с выбранной до неё.</summary> [RelayCommand] private void AddIntersectionPoint() { if (SelectedNode is not { } second || _document.Draft.Get(second) is not CurveNode || _previousSelection is not { } first || _document.Draft.Get(first) is not CurveNode) { EditorError = "Выберите одну за другой две линии"; return; } if (first == second) { EditorError = "Нужны две разные линии"; return; } var node = new IntersectionPoint(NodeId.New(), NextName("X"), first, second); _document.Undo.Do(new AddNodeAction(_document.Draft, node)); EditorError = null; SelectedNode = node.Id; } /// <summary>Создаёт точку по координатам, не привязанную к другим построениям.</summary> [RelayCommand] private void AddFreePoint() { try { var node = new FreePoint( NodeId.New(), string.IsNullOrWhiteSpace(NewPointName) ? NextName() : NewPointName.Trim(), _document.ParseFormula(NewPointDistance), _document.ParseFormula(NewPointAngle)); _document.Undo.Do(new AddNodeAction(_document.Draft, node)); NewPointName = string.Empty; EditorError = null; SelectedNode = node.Id; } catch (FormulaException error) { EditorError = $"{error.Message} (позиция {error.Position + 1})"; } } /// <summary>Соединяет две последние точки отрезком.</summary> [RelayCommand] private void AddSegment() { var points = _document.Draft.Nodes.OfType<PointNode>().ToArray(); if (points.Length < 2) { EditorError = "Нужны хотя бы две точки"; return; } var start = SelectedNode ?? points[^2].Id; var end = points[^1].Id; if (start == end) { EditorError = "Отрезок нельзя построить из одной точки"; return; } var name = NextName("Отрезок"); _document.Undo.Do(new AddNodeAction(_document.Draft, new SegmentNode(NodeId.New(), name, start, end))); EditorError = null; Recalculate(); } /// <summary> /// Строит кубическую кривую на четырёх выбранных по порядку точках. /// </summary> /// <remarks> /// Порядок — начало, две направляющие, конец: так кривая и записана в /// модели, и так её задают в любом чертёжном средстве. Без этого /// инструмента редактор строил одни отрезки, тогда как горловина, пройма и /// окат — кривые, и поставляемая с программой конструкция средствами /// программы не повторялась. /// </remarks> [RelayCommand] private void AddCubicCurve() { var picked = _pickedPoints.Where(id => _document.Draft.Contains(id)).ToArray(); if (picked.Length < CurvePointCount) { EditorError = $"Выберите одну за другой четыре точки: начало, две направляющие и конец " + $"(выбрано {picked.Length})"; return; } var node = new CubicCurveNode( NodeId.New(), NextName("Кривая"), picked[0], picked[1], picked[2], picked[3]); _document.Undo.Do(new AddNodeAction(_document.Draft, node)); _pickedPoints.Clear(); OnPropertyChanged(nameof(PickedPointNames)); EditorError = null; SelectedNode = node.Id; } /// <summary>Снимок перемещаемого построения до начала перетаскивания.</summary> private DraftNode? _moveBefore; /// <summary>Выбранное построение можно перетащить указателем.</summary> public bool CanMoveSelected => SelectedNode is { } id && _document.Draft.TryGet(id, out var node) && node is FreePoint or OffsetPoint or PointOnCurve; /// <summary> /// Начинает перетаскивание выбранного построения. /// </summary> /// <remarks> /// Возвращает ложь, если построение не перемещается: положение точки /// пересечения задано пересекаемыми линиями, а линия целиком следует за /// своими опорными точками. Тащить их значило бы откреплять построение от /// того, на что оно ссылается, а это уже не перемещение, а другая /// конструкция. /// </remarks> public bool BeginMove() { if (SelectedNode is not { } id || !_document.Draft.TryGet(id, out var node) || node is null) return false; if (!CanMoveSelected) { EditorError = node switch { IntersectionPoint => "Точка пересечения не перемещается: её задают пересекаемые линии", CurveNode => "Линия следует за своими точками — перемещайте их", _ => "Это построение не перемещается" }; return false; } _moveBefore = node.Clone(); return true; } /// <summary> /// Ведёт перемещаемое построение за указателем. /// </summary> /// <remarks> /// В журнал отмены ничего не пишется: за одно перетаскивание туда попадёт /// одно действие, и произойдёт это по отпускании кнопки. /// </remarks> public void MoveTo(Point2 world) { if (_moveBefore is null || SelectedNode is not { } id) return; if (!_document.Draft.TryGet(id, out var node) || node is null) return; var evaluation = _grading.Evaluate(ActiveSize); var unit = _document.FormulaUnit; switch (node) { case FreePoint free: _document.Draft.Update(id, target => { ((FreePoint)target).X = Formula.Constant(world.X.To(unit)); ((FreePoint)target).Y = Formula.Constant(world.Y.To(unit)); }); break; case OffsetPoint offset: { if (!evaluation.Points.TryGetValue(offset.Origin, out var origin)) return; // Точка остаётся привязанной к своему началу: меняются // расстояние и угол, а ссылка на начало не трогается. var delta = world - origin; _document.Draft.Update(id, target => { ((OffsetPoint)target).Distance = Formula.Constant(delta.Magnitude.To(unit)); ((OffsetPoint)target).AngleDegrees = Formula.Constant(delta.Degrees); }); break; } case PointOnCurve onCurve: { if (!evaluation.Curves.TryGetValue(onCurve.Curve, out var curve) || curve is null) return; var position = NearestOnCurve(curve, world); _document.Draft.Update(id, target => ((PointOnCurve)target).Position = Formula.Constant( onCurve.Mode == PointOnCurveMode.Parameter ? position.Parameter : position.Distance.To(unit))); break; } default: return; } Recalculate(); } /// <summary> /// Завершает перетаскивание, записав его в журнал одним действием. /// </summary> /// <remarks> /// Предупреждает, если перетаскивание затёрло формулу: точка, отложенная на /// «Ог/4 + 2», после перемещения стоит на числе и градироваться перестаёт. /// Сказать об этом обязательно — потеря обнаружилась бы только на другом /// размере, когда связь с перетаскиванием уже не вспомнить. /// </remarks> public void EndMove() { if (_moveBefore is not { } before || SelectedNode is not { } id) return; _moveBefore = null; if (!_document.Draft.TryGet(id, out var node) || node is null) return; // В неосновном размере перетаскивание не правит конструкцию, а // записывает поправку на этот размер: правка формулы разъехалась бы по // всему ряду, тогда как исправить требовалось один размер. if (!IsBaseSizeActive) { RecordAdjustment(before, node); return; } _document.Undo.Do(new MoveNodeAction(_document.Draft, before, node.Clone())); var lost = LostFormulas(before).ToArray(); EditorError = lost.Length == 0 ? null : $"Перемещение заменило числом: {string.Join(", ", lost)}. " + "Эти параметры больше не следуют за мерками"; RebuildFormulaFields(); Recalculate(); } /// <summary> /// Записывает перетаскивание в неосновном размере как поправку на него. /// </summary> /// <remarks> /// Конструкция возвращается в прежний вид, а разница между тем, где точка /// оказалась, и тем, где её ставит пересчёт, ложится отдельным слоем. Так /// исправляется один размер, не задевая остальные: правка формулы разошлась /// бы по всему ряду. /// </remarks> private void RecordAdjustment(DraftNode before, DraftNode moved) { var id = moved.Id; var dragged = moved.Clone(); // Собственная поправка снимается на время замера: сравнивать нужно то, // где точка стоит без неё, с тем, куда её притащили. Иначе прежняя // поправка вошла бы в замер вторично, и точка уехала бы вдвое дальше // указателя. Поправки предков при этом остаются: пользователь тащил // точку относительно того, что видел на экране. var had = _document.Adjustments.TryGet(ActiveSize, id, out var previous); if (had) _grading.RemoveAdjustment(ActiveSize, id); _document.Draft.Restore(before.Clone()); if (!_grading.Evaluate(ActiveSize).Points.TryGetValue(id, out var original)) { Rollback(); return; } _document.Draft.Restore(dragged); if (!_grading.Evaluate(ActiveSize).Points.TryGetValue(id, out var intended)) { Rollback(); return; } // Конструкция возвращается: правится не она, а слой поверх пересчёта. _document.Draft.Restore(before.Clone()); _grading.Adjust(ActiveSize, id, intended - original); _document.MarkModified(); EditorError = null; RebuildAdjustments(); RebuildFormulaFields(); Recalculate(); return; // Замер не удался: возвращаем и конструкцию, и снятую поправку. void Rollback() { _document.Draft.Restore(before.Clone()); if (had) _grading.Adjust(ActiveSize, id, previous); RebuildAdjustments(); Recalculate(); } } /// <summary>Ручные поправки документа для показа и отмены.</summary> public ObservableCollection<AdjustmentRow> Adjustments { get; } public bool HasAdjustments => Adjustments.Count > 0; private void RebuildAdjustments() { Adjustments.Clear(); foreach (var adjustment in _document.Adjustments.All()) Adjustments.Add(new AdjustmentRow( adjustment.Size, adjustment.Node, _document.Draft.TryGet(adjustment.Node, out var node) && node is not null ? node.Name : "удалённое построение", adjustment.Offset, _document.DisplayUnit)); OnPropertyChanged(nameof(HasAdjustments)); } /// <summary>Снимает поправку: точка возвращается к вычисленному положению.</summary> [RelayCommand] private void RemoveAdjustment(AdjustmentRow? row) { if (row is null) return; _grading.RemoveAdjustment(row.Size, row.Node); _document.MarkModified(); RebuildAdjustments(); Recalculate(); } /// <summary>Параметры построения, которые до перемещения зависели от мерок или других построений.</summary> private static IEnumerable<string> LostFormulas(DraftNode before) => before switch { FreePoint free => new (string Name, Formula Value)[] { ("координата x", free.X), ("координата y", free.Y) } .Where(item => IsDerived(item.Value)).Select(item => item.Name), OffsetPoint offset => new (string Name, Formula Value)[] { ("расстояние", offset.Distance), ("угол", offset.AngleDegrees) } .Where(item => IsDerived(item.Value)).Select(item => item.Name), PointOnCurve onCurve when IsDerived(onCurve.Position) => ["положение на линии"], _ => [] }; /// <summary>Формула ссылается на мерки или построения, а не сводится к числу.</summary> private static bool IsDerived(Formula formula) => formula.Measurements.Count > 0 || formula.Nodes.Count > 0; /// <summary>Ближайшая к точке позиция на линии.</summary> private static (double Parameter, Length Distance) NearestOnCurve(ICurve curve, Point2 world) { const int samples = 200; var bestParameter = 0.0; var bestDistance = long.MaxValue; for (var index = 0; index <= samples; index++) { var parameter = index / (double)samples; var distance = curve.PointAt(parameter).DistanceTo(world).Units; if (distance < bestDistance) { bestDistance = distance; bestParameter = parameter; } } return (bestParameter, Length.FromUnits((long)(curve.Length.Units * bestParameter))); } /// <summary>Точки обводки, снятые с подложки указателем.</summary> private readonly List<Point2> _tracePoints = []; /// <summary>Идёт обводка по подложке.</summary> [ObservableProperty] private bool _isTracing; /// <summary>Что делать дальше при обводке.</summary> [ObservableProperty] private string _traceHint = string.Empty; /// <summary> /// Начинает обводку контура по подложке. /// </summary> /// <remarks> /// Привязка берётся из тех же двух точек, что выбраны последними: пара /// задаёт и направление, и длину — этого достаточно для подобия, а тройка /// искажала бы обводку независимым растяжением по осям. /// /// Обводка без привязки разрешена намеренно. Запрещать её значило бы /// требовать построить каркас прежде, чем взглянуть на образец, — а /// работают обычно наоборот. Неградируемой такая кривая помечается и /// перечисляется до вывода ряда. /// </remarks> [RelayCommand] private void StartTracing() { _tracePoints.Clear(); IsTracing = true; EditorError = null; var anchored = TraceAnchors() is not null; if (!anchored) Underlays.NoteUnanchoredTracing(); TraceHint = anchored ? "Обводка: укажите четыре точки — начало, две направляющие, конец" : "Обводка без привязки: кривая не будет градироваться. " + "Выберите две точки каркаса до начала, чтобы привязать её"; } [RelayCommand] private void CancelTracing() { _tracePoints.Clear(); IsTracing = false; TraceHint = string.Empty; } /// <summary>Пара точек каркаса для привязки обводки, если она выбрана.</summary> private (NodeId From, NodeId To)? TraceAnchors() { var picked = _pickedPoints .Where(id => _document.Draft.Contains(id)) .Distinct() .ToArray(); return picked.Length >= 2 ? (picked[^2], picked[^1]) : null; } /// <summary> /// Принимает точку обводки. По четвёртой создаёт кривую. /// </summary> public void AddTracePoint(Point2 world) { if (!IsTracing) return; _tracePoints.Add(world); if (_tracePoints.Count < CurvePointCount) { TraceHint = $"Обводка: указано {_tracePoints.Count} из {CurvePointCount}"; return; } var name = NextName("Обводка"); var node = BuildTracedCurve(name); _tracePoints.Clear(); IsTracing = false; TraceHint = string.Empty; if (node is null) return; _document.Undo.Do(new AddNodeAction(_document.Draft, node)); Underlays.Refresh(); SelectedNode = node.Id; } private TracedCurveNode? BuildTracedCurve(string name) { var points = _tracePoints; if (TraceAnchors() is not { } anchors) return TracedCurveNode.Free( NodeId.New(), name, points[0], points[1], points[2], points[3]); var evaluation = _grading.Evaluate(ActiveSize); if (!evaluation.Points.TryGetValue(anchors.From, out var from) || !evaluation.Points.TryGetValue(anchors.To, out var to)) { EditorError = "Опорные точки не вычислены: привязать обводку не к чему"; return null; } if (from.DistanceTo(to) <= Length.Zero) { EditorError = "Опорные точки совпадают: направление привязки не определено"; return null; } return TracedCurveNode.Trace( NodeId.New(), name, anchors.From, anchors.To, from, to, points[0], points[1], points[2], points[3]); } /// <summary>Удаляет выбранное построение, предупреждая о зависящих от него.</summary> [RelayCommand] private void DeleteSelected() { if (SelectedNode is not { } id) return; var dependents = _document.Draft.GetDependents(id); if (dependents.Count > 0 && !ConfirmDeletion(dependents)) return; _document.Undo.Do(new RemoveNodeAction(_document.Draft, id)); SelectedNode = null; Recalculate(); } /// <summary> /// Подтверждение удаления построения с зависимыми. /// </summary> /// <remarks> /// Пока подтверждение выдаётся сообщением в строке состояния и требует /// повторного нажатия: диалоги подключаются вместе с остальным /// интерфейсом документа. /// </remarks> private bool ConfirmDeletion(IReadOnlyList<DraftNode> dependents) { var names = string.Join(", ", dependents.Select(node => node.Name)); if (_pendingDeletionOf == SelectedNode) { _pendingDeletionOf = null; return true; } _pendingDeletionOf = SelectedNode; EditorError = $"Будут удалены также: {names}. Нажмите удаление ещё раз для подтверждения"; return false; } private NodeId? _pendingDeletionOf; [RelayCommand] private void Undo() { _document.Undo.Undo(); Recalculate(); } [RelayCommand] private void Redo() { _document.Undo.Redo(); Recalculate(); } private string NextName(string prefix = "Т") { for (var index = 1; ; index++) { var candidate = $"{prefix}{index}"; if (_document.Draft.FindByName(candidate) is null) return candidate; } } }