/
docNemo
/
clothes-graph
Обзор
Документация
Войти
/
docNemo
/
clothes-graph
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
main
src/ClothesGraph.App/Controls/PrintPreview.cs
367 строк
13 KB
docNemo
Фундамент продукта: параметрическое построение выкроек, печать, установщик
08 авг 2026, 22:17
08 авг 2026, 22:17
cd47cce
Код
Авторство
О чём код?
using System; using System.Globalization; using System.Linq; using Avalonia; using Avalonia.Controls; using Avalonia.Input; using Avalonia.Media; using ClothesGraph.Geometry; using ClothesGraph.Printing; namespace ClothesGraph.App.Controls; /// <summary> /// Предпросмотр печати: сетка листов с содержимым и размещение деталей мышью. /// </summary> /// <remarks> /// Показывает не «как выглядит выкройка», а как она ляжет на бумагу: границы /// листов, их обозначения и метки склейки. Здесь же деталь двигают на нужный /// лист — раскладку удобно править там, где видно её последствия. /// </remarks> public sealed class PrintPreview : Control { private const double GapPixels = 12; private const double PaddingPixels = 16; /// <summary>Шаг поворота колесом мыши.</summary> private const double RotationStepDegrees = 15; private static readonly IBrush Paper = new SolidColorBrush(Color.FromRgb(0xFC, 0xFC, 0xFA)); private static readonly IBrush Shadow = new SolidColorBrush(Color.FromArgb(0x30, 0, 0, 0)); private static readonly IPen SheetBorder = new Pen( new SolidColorBrush(Color.FromRgb(0xB0, 0xB6, 0xC0)), 1); private static readonly IPen PrintableBorder = new Pen( new SolidColorBrush(Color.FromRgb(0xD8, 0xDC, 0xE4)), 1, new DashStyle([3, 3], 0)); private static readonly IPen SelectionBorder = new Pen( new SolidColorBrush(Color.FromRgb(0x1D, 0x6F, 0xB8)), 2); private static readonly IBrush SelectionFill = new SolidColorBrush( Color.FromArgb(0x22, 0x1D, 0x6F, 0xB8)); public static readonly StyledProperty<SheetGrid?> GridProperty = AvaloniaProperty.Register<PrintPreview, SheetGrid?>(nameof(Grid)); public static readonly StyledProperty<PrintContent?> ContentSourceProperty = AvaloniaProperty.Register<PrintPreview, PrintContent?>(nameof(ContentSource)); public static readonly StyledProperty<PlacedPiece?> SelectedPieceProperty = AvaloniaProperty.Register<PrintPreview, PlacedPiece?>(nameof(SelectedPiece)); static PrintPreview() { AffectsRender<PrintPreview>(GridProperty, ContentSourceProperty, SelectedPieceProperty); } public PrintPreview() { Focusable = true; Cursor = new Cursor(StandardCursorType.Arrow); } public SheetGrid? Grid { get => GetValue(GridProperty); set => SetValue(GridProperty, value); } public PrintContent? ContentSource { get => GetValue(ContentSourceProperty); set => SetValue(ContentSourceProperty, value); } public PlacedPiece? SelectedPiece { get => GetValue(SelectedPieceProperty); set => SetValue(SelectedPieceProperty, value); } /// <summary>Деталь передвинули или повернули; раскладку нужно пересчитать.</summary> public event EventHandler<PlacedPiece>? PiecePlaced; private double _pixelsPerUnit; private double _offsetX; private double _offsetY; private double _sheetWidth; private double _sheetHeight; private PlacedPiece? _dragging; private Point2 _dragStartWorld; private Offset2 _dragStartOffset; public override void Render(DrawingContext context) { base.Render(context); var grid = Grid; var content = ContentSource; if (grid is null || content is null || Bounds.Width < 10 || Bounds.Height < 10) return; var layout = grid.Layout; var totalWidthUnits = layout.PaperWidth.Units * (double)grid.Columns; var totalHeightUnits = layout.PaperHeight.Units * (double)grid.Rows; var availableWidth = Bounds.Width - PaddingPixels * 2 - GapPixels * (grid.Columns - 1); var availableHeight = Bounds.Height - PaddingPixels * 2 - GapPixels * (grid.Rows - 1); _pixelsPerUnit = Math.Min( availableWidth / totalWidthUnits, availableHeight / totalHeightUnits); if (_pixelsPerUnit <= 0) return; _sheetWidth = layout.PaperWidth.Units * _pixelsPerUnit; _sheetHeight = layout.PaperHeight.Units * _pixelsPerUnit; _offsetX = (Bounds.Width - (_sheetWidth * grid.Columns + GapPixels * (grid.Columns - 1))) / 2; _offsetY = (Bounds.Height - (_sheetHeight * grid.Rows + GapPixels * (grid.Rows - 1))) / 2; var composer = new SheetComposer(grid, content); foreach (var sheet in grid.Sheets) { var origin = OriginOf(sheet.Column, sheet.Row); var paperRect = new Rect(origin.X, origin.Y, _sheetWidth, _sheetHeight); context.FillRectangle(Shadow, paperRect.Translate(new Vector(2, 2))); context.FillRectangle(Paper, paperRect); context.DrawRectangle(SheetBorder, paperRect); // Граница печатной области: за неё принтер не печатает, // и пользователю важно видеть, что туда ничего не попало. context.DrawRectangle(PrintableBorder, new Rect( origin.X + layout.Margin.Units * _pixelsPerUnit, origin.Y + layout.Margin.Units * _pixelsPerUnit, layout.PrintableWidth.Units * _pixelsPerUnit, layout.PrintableHeight.Units * _pixelsPerUnit)); using (context.PushClip(paperRect)) composer.Compose(new AvaloniaSheetCanvas(context, origin, _pixelsPerUnit), sheet); } DrawSelection(context, grid); DrawCaption(context, grid); } private Point OriginOf(int column, int row) => new(_offsetX + column * (_sheetWidth + GapPixels), _offsetY + row * (_sheetHeight + GapPixels)); private void DrawSelection(DrawingContext context, SheetGrid grid) { if (SelectedPiece is not { } piece) return; var (min, max) = piece.Bounds(); // Рамка обводит деталь на каждом листе, куда она попала: крупная // деталь занимает несколько листов, и выделять её на одном значило бы // показывать лишь часть выбранного. foreach (var sheet in grid.Sheets) { var topLeft = ToScreen(new Point2(min.X, max.Y), sheet, grid); var bottomRight = ToScreen(new Point2(max.X, min.Y), sheet, grid); var rect = new Rect(topLeft, bottomRight); var paper = new Rect( OriginOf(sheet.Column, sheet.Row), new Size(_sheetWidth, _sheetHeight)); if (!paper.Intersects(rect)) continue; using (context.PushClip(paper)) { context.FillRectangle(SelectionFill, rect); context.DrawRectangle(SelectionBorder, rect); } } } private void DrawCaption(DrawingContext context, SheetGrid grid) { var caption = string.Create(CultureInfo.CurrentCulture, $"{grid.Count} л. · {grid.Columns}×{grid.Rows} · {grid.Layout.Size.Name}"); if (SelectedPiece is { } piece) caption += $" · выбрано: {piece.Piece.Name}"; var text = new FormattedText( caption, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, new Typeface(FontFamily.Default), 12, new SolidColorBrush(Color.FromRgb(0x9A, 0xA2, 0xB4))); context.DrawText(text, new Point(PaddingPixels, Bounds.Height - text.Height - 6)); } private Point ToScreen(Point2 world, Sheet sheet, SheetGrid grid) { var layout = grid.Layout; var origin = OriginOf(sheet.Column, sheet.Row); var paperX = layout.Margin.Units + (world.X.Units - sheet.Origin.X.Units); var paperY = layout.Margin.Units + (sheet.Origin.Y.Units + layout.PrintableHeight.Units - world.Y.Units); return new Point(origin.X + paperX * _pixelsPerUnit, origin.Y + paperY * _pixelsPerUnit); } /// <summary> /// Точка чертежа под курсором. /// </summary> /// <remarks> /// Обратное к выводу: сначала определяется лист, на который попал курсор, /// затем положение внутри него переводится в координаты чертежа. Без /// привязки к листу перевод неоднозначен — соседние листы показывают /// разные участки чертежа. /// </remarks> private Point2? ToWorld(Point screen, SheetGrid grid) { if (_pixelsPerUnit <= 0) return null; var column = (int)Math.Floor((screen.X - _offsetX) / (_sheetWidth + GapPixels)); var row = (int)Math.Floor((screen.Y - _offsetY) / (_sheetHeight + GapPixels)); column = Math.Clamp(column, 0, grid.Columns - 1); row = Math.Clamp(row, 0, grid.Rows - 1); var sheet = grid[column, row]; var origin = OriginOf(column, row); var layout = grid.Layout; var paperX = (screen.X - origin.X) / _pixelsPerUnit; var paperY = (screen.Y - origin.Y) / _pixelsPerUnit; var worldX = sheet.Origin.X.Units + paperX - layout.Margin.Units; var worldY = sheet.Origin.Y.Units + layout.PrintableHeight.Units + layout.Margin.Units - paperY; return new Point2( Length.FromUnits((long)Math.Round(worldX, MidpointRounding.AwayFromZero)), Length.FromUnits((long)Math.Round(worldY, MidpointRounding.AwayFromZero))); } private PlacedPiece? HitTest(Point2 world) { if (ContentSource is not { } content) return null; // Перебор с конца: деталь, нарисованная последней, лежит сверху, // и захватывать курсором нужно именно её. for (var index = content.Pieces.Count - 1; index >= 0; index--) { var piece = content.Pieces[index]; if (Contains(piece.PlacedOutline, world)) return piece; } return null; } private static bool Contains(System.Collections.Generic.IReadOnlyList<Point2> outline, Point2 point) { if (outline.Count < 3) return false; var inside = false; for (int current = 0, previous = outline.Count - 1; current < outline.Count; previous = current++) { var a = outline[current]; var b = outline[previous]; if (a.Y.Units > point.Y.Units != b.Y.Units > point.Y.Units && point.X.Units < (double)(b.X.Units - a.X.Units) * (point.Y.Units - a.Y.Units) / (b.Y.Units - a.Y.Units) + a.X.Units) { inside = !inside; } } return inside; } protected override void OnPointerPressed(PointerPressedEventArgs e) { base.OnPointerPressed(e); if (Grid is not { } grid || ToWorld(e.GetPosition(this), grid) is not { } world) return; var hit = HitTest(world); SelectedPiece = hit; if (hit is null) return; _dragging = hit; _dragStartWorld = world; _dragStartOffset = hit.Offset; e.Pointer.Capture(this); Cursor = new Cursor(StandardCursorType.SizeAll); } protected override void OnPointerMoved(PointerEventArgs e) { base.OnPointerMoved(e); if (_dragging is null || Grid is not { } grid) return; if (ToWorld(e.GetPosition(this), grid) is not { } world) return; _dragging.Offset = new Offset2( _dragStartOffset.X + (world.X - _dragStartWorld.X), _dragStartOffset.Y + (world.Y - _dragStartWorld.Y)); InvalidateVisual(); } protected override void OnPointerReleased(PointerReleasedEventArgs e) { base.OnPointerReleased(e); Cursor = new Cursor(StandardCursorType.Arrow); e.Pointer.Capture(null); if (_dragging is null) return; var moved = _dragging; _dragging = null; // Раскладка листов пересчитывается только после отпускания кнопки: // на каждом движении мыши это перестраивало бы сетку и деталь // прыгала бы под курсором. PiecePlaced?.Invoke(this, moved); } protected override void OnPointerWheelChanged(PointerWheelEventArgs e) { base.OnPointerWheelChanged(e); if (SelectedPiece is not { } piece) return; // Точный поворот с зажатой клавишей: шаг в пятнадцать градусов удобен // для раскладки, но долевую нить иногда нужно выставить точнее. var step = e.KeyModifiers.HasFlag(KeyModifiers.Shift) ? 1 : RotationStepDegrees; piece.RotationDegrees += Math.Sign(e.Delta.Y) * step; InvalidateVisual(); PiecePlaced?.Invoke(this, piece); e.Handled = true; } }