/
docNemo
/
clothes-graph
Обзор
Документация
Войти
/
docNemo
/
clothes-graph
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
main
src/ClothesGraph.Geometry/ContourOffset.cs
273 строки
12 KB
docNemo
Фундамент продукта: параметрическое построение выкроек, печать, установщик
08 авг 2026, 22:17
08 авг 2026, 22:17
cd47cce
Код
Авторство
О чём код?
using System; using System.Collections.Generic; using System.Linq; using Clipper2Lib; namespace ClothesGraph.Geometry; /// <summary>Предупреждение о проблемном участке отступа.</summary> public sealed record OffsetWarning(string Message, Point2? Where = null); /// <summary>Результат построения линии припуска.</summary> public sealed class ContourOffsetResult { internal ContourOffsetResult( IReadOnlyList<IReadOnlyList<Point2>> outlines, IReadOnlyList<OffsetWarning> warnings) { Outlines = outlines; Warnings = warnings; } /// <summary> /// Полученные контуры. В норме один; несколько означает, что припуск /// разорвал деталь на части. /// </summary> public IReadOnlyList<IReadOnlyList<Point2>> Outlines { get; } public IReadOnlyList<OffsetWarning> Warnings { get; } public bool IsSound => Outlines.Count == 1 && Warnings.Count == 0; public IReadOnlyList<Point2> Primary => Outlines.Count > 0 ? Outlines[0] : Array.Empty<Point2>(); } /// <summary> /// Построение линии припуска на швы — отступа от контура детали наружу. /// </summary> /// <remarks> /// Самая капризная геометрия продукта. Равномерный отступ отдан библиотеке /// отсечения, которая работает в целых числах — ровно тех, в которых хранится /// чертёж, поэтому преобразований и потери точности здесь нет. Переменная по /// участкам ширина строится вручную: библиотека такого не умеет, а разная /// ширина припуска по низу и по боковым швам — обычное дело. /// </remarks> public static class ContourOffset { private const double MiterLimit = 2.0; /// <summary>Отступ с одинаковой шириной по всему контуру.</summary> public static ContourOffsetResult Build(IReadOnlyList<Point2> contour, Length width) { if (contour.Count < 3) return new ContourOffsetResult([], [new OffsetWarning("Контур короче трёх точек")]); if (width.Units == 0) return new ContourOffsetResult([contour], []); var path = ToPath(contour); var oriented = Clipper.Area(path) > 0 ? path : Reverse(path); var inflated = Clipper.InflatePaths( new Paths64 { oriented }, width.Units, JoinType.Miter, EndType.Polygon, MiterLimit, 0); return Interpret(inflated, oriented, width); } /// <summary> /// Отступ с разной шириной на участках. /// </summary> /// <param name="contour">Вершины контура.</param> /// <param name="widthPerEdge"> /// Ширина припуска для каждого ребра: элемент с номером i относится к ребру /// от вершины i к вершине i+1, последний — к замыкающему ребру. /// </param> /// <remarks> /// Ширина принадлежит именно ребру, а не вершине: припуск задаётся по /// участкам контура — «по низу четыре сантиметра, по бокам один», — и /// приписывать его точкам бессмысленно. В вершине, где сходятся рёбра /// с разной шириной, смещённые линии не пересекаются в одной точке, /// поэтому там появляется переход. /// </remarks> public static ContourOffsetResult Build( IReadOnlyList<Point2> contour, IReadOnlyList<Length> widthPerEdge) { if (contour.Count < 3) return new ContourOffsetResult([], [new OffsetWarning("Контур короче трёх точек")]); if (widthPerEdge.Count != contour.Count) throw new ArgumentException( "Ширина припуска должна быть задана для каждого ребра контура", nameof(widthPerEdge)); // Одинаковая ширина по всему контуру решается библиотекой надёжнее // и с корректной обработкой углов, поэтому этот случай выделен. var first = widthPerEdge[0]; if (widthPerEdge.All(width => width == first)) return Build(contour, first); var path = ToPath(contour); var counterClockwise = Clipper.Area(path) > 0; var vertices = counterClockwise ? contour : contour.Reverse().ToArray(); // При развороте контура рёбра тоже меняют порядок: ребро, шедшее // из вершины i, после разворота выходит из другой вершины. var widths = counterClockwise ? widthPerEdge : Enumerable.Range(0, widthPerEdge.Count) .Select(index => widthPerEdge[widthPerEdge.Count - 2 - index < 0 ? widthPerEdge.Count - 1 : widthPerEdge.Count - 2 - index]) .ToArray(); var raw = OffsetByEdge(vertices, widths); // Смещение рёбер оставляет петли на вогнутых участках; объединение // с самим собой их убирает, попутно показывая, распалась ли деталь. var cleaned = Clipper.Union(new Paths64 { raw }, FillRule.NonZero); return Interpret(cleaned, ToPath(vertices), widths.Max()); } /// <summary> /// Смещает каждое ребро наружу на свою ширину и сшивает результат. /// </summary> /// <remarks> /// Соседние смещённые рёбра сводятся в точке пересечения, когда она есть /// и лежит недалеко: так получается обычный острый угол. Когда ширины /// разные или рёбра расходятся, вместо угла ставится перемычка между /// концами — иначе на переходе ширины возник бы разрыв. /// </remarks> private static Path64 OffsetByEdge( IReadOnlyList<Point2> contour, IReadOnlyList<Length> widths) { var count = contour.Count; var starts = new (double X, double Y)[count]; var ends = new (double X, double Y)[count]; for (var index = 0; index < count; index++) { var from = contour[index]; var to = contour[(index + 1) % count]; var direction = Normalise(to.X.Units - from.X.Units, to.Y.Units - from.Y.Units); // Наружная нормаль для контура, обходимого против часовой стрелки. var normal = (X: direction.Y, Y: -direction.X); var width = (double)widths[index].Units; starts[index] = (from.X.Units + normal.X * width, from.Y.Units + normal.Y * width); ends[index] = (to.X.Units + normal.X * width, to.Y.Units + normal.Y * width); } var result = new Path64(count * 2); var limit = widths.Max().Units * MiterLimit; for (var index = 0; index < count; index++) { var next = (index + 1) % count; var corner = Intersect(starts[index], ends[index], starts[next], ends[next]); var withinLimit = corner is { } point && Distance(point, ends[index]) <= limit && Distance(point, starts[next]) <= limit; if (withinLimit) { result.Add(ToPoint(corner!.Value)); } else { result.Add(ToPoint(ends[index])); result.Add(ToPoint(starts[next])); } } return result; } private static Point64 ToPoint((double X, double Y) value) => new((long)Math.Round(value.X, MidpointRounding.AwayFromZero), (long)Math.Round(value.Y, MidpointRounding.AwayFromZero)); private static double Distance((double X, double Y) a, (double X, double Y) b) => Math.Sqrt((a.X - b.X) * (a.X - b.X) + (a.Y - b.Y) * (a.Y - b.Y)); /// <summary>Пересечение двух прямых, заданных отрезками; null — параллельны.</summary> private static (double X, double Y)? Intersect( (double X, double Y) a1, (double X, double Y) a2, (double X, double Y) b1, (double X, double Y) b2) { var ax = a2.X - a1.X; var ay = a2.Y - a1.Y; var bx = b2.X - b1.X; var by = b2.Y - b1.Y; var denominator = ax * by - ay * bx; if (Math.Abs(denominator) < 1e-6) return null; var t = ((b1.X - a1.X) * by - (b1.Y - a1.Y) * bx) / denominator; return (a1.X + ax * t, a1.Y + ay * t); } private static ContourOffsetResult Interpret(Paths64 result, Path64 original, Length width) { var warnings = new List<OffsetWarning>(); if (result.Count == 0) { warnings.Add(new OffsetWarning( "Припуск полностью поглотил контур: ширина слишком велика для этой детали")); return new ContourOffsetResult([], warnings); } if (result.Count > 1) warnings.Add(new OffsetWarning( $"Припуск разбил контур на части ({result.Count}). " + "Вероятно, ширина превышает радиус кривизны на вогнутом участке")); var originalArea = Math.Abs(Clipper.Area(original)); var resultArea = Math.Abs(Clipper.Area(result)); // Линия припуска идёт наружу, поэтому охватываемая площадь обязана // вырасти. Если она не выросла, отступ где-то свернулся внутрь. if (resultArea < originalArea) warnings.Add(new OffsetWarning( "Линия припуска местами уходит внутрь контура: проверьте вогнутые участки")); var outlines = result .OrderByDescending(path => Math.Abs(Clipper.Area(path))) .Select(FromPath) .ToArray(); _ = width; return new ContourOffsetResult(outlines, warnings); } private static (double X, double Y) Normalise(double x, double y) { var length = Math.Sqrt(x * x + y * y); return length < 1e-9 ? (0, 0) : (x / length, y / length); } private static Path64 ToPath(IReadOnlyList<Point2> contour) { var path = new Path64(contour.Count); foreach (var point in contour) path.Add(new Point64(point.X.Units, point.Y.Units)); return path; } private static Path64 Reverse(Path64 path) { var reversed = new Path64(path.Count); for (var index = path.Count - 1; index >= 0; index--) reversed.Add(path[index]); return reversed; } private static IReadOnlyList<Point2> FromPath(Path64 path) => path.Select(point => new Point2( Length.FromUnits(point.X), Length.FromUnits(point.Y))).ToArray(); }