diff --git a/Rozpisky.Tests/RecordingRenderer.cs b/Rozpisky.Tests/RecordingRenderer.cs index 54fd95a..b7515f2 100644 --- a/Rozpisky.Tests/RecordingRenderer.cs +++ b/Rozpisky.Tests/RecordingRenderer.cs @@ -50,9 +50,13 @@ public sealed class RecordingRenderer : IProfileRenderer TextStyle style = default, double widthFactor = 1, double maxWidthMm = 0) => Texts.Add(new TextCall(x, y, text, heightMm, h, v, rotationDeg, style, widthFactor, maxWidthMm)); - public void Fill(IReadOnlyList loops) + /// Druh výplně u jednotlivých volání Fill (plná / vzor a jeho čáry). + public List Vyplne { get; } = new(); + + public void Fill(IReadOnlyList loops, Vypln vypln = default) { FillContours.Add(loops); + Vyplne.Add(vypln); Fills.Add(loops.Select(Zplosti).ToList()); } diff --git a/Rozpisky.Tests/SrafaVzorTesty.cs b/Rozpisky.Tests/SrafaVzorTesty.cs new file mode 100644 index 0000000..d7b80b0 --- /dev/null +++ b/Rozpisky.Tests/SrafaVzorTesty.cs @@ -0,0 +1,119 @@ +using Rozpisky.Dxf; +using Rozpisky.Rendering; +using Xunit; + +namespace Rozpisky.Tests; + +/// +/// Šrafy se vzorem. Logo md_sfdi.dxf má 3× vzor ANSI31 (šikmé čáry); dřív se každá +/// šrafa kreslila jako plná výplň, takže z něj byl v náhledu, PNG, PDF i DXF černý blok. +/// +public class SrafaVzorTesty +{ + private static RecordingRenderer Prehraj(string logo) + { + var g = new RecordingRenderer(); + new DxfTemplate(TestData.Logo(logo)).Replay(g); + return g; + } + + [Fact] + public void Ansi31SeRozvineNaCary() + { + var g = Prehraj("md_sfdi.dxf"); + var vzorovane = g.Vyplne.Where(v => !v.JeSolid).ToList(); + + Assert.Equal(3, vzorovane.Count); + foreach (var v in vzorovane) + { + Assert.Equal("ANSI31", v.Vzor); + Assert.NotNull(v.Cary); + Assert.NotEmpty(v.Cary!); + } + } + + [Fact] + public void CaryLeziUvnitrObrysuSrafy() + { + var g = Prehraj("md_sfdi.dxf"); + for (int i = 0; i < g.Vyplne.Count; i++) + { + if (g.Vyplne[i].JeSolid) continue; + var (minX, minY, maxX, maxY) = Bbox(g.Fills[i]); + foreach (var u in g.Vyplne[i].Cary!) + { + Assert.InRange(u.X1, minX - 0.01, maxX + 0.01); + Assert.InRange(u.Y1, minY - 0.01, maxY + 0.01); + Assert.InRange(u.X2, minX - 0.01, maxX + 0.01); + Assert.InRange(u.Y2, minY - 0.01, maxY + 0.01); + } + } + } + + [Fact] + public void CaryAnsi31JsouRovnobezneA45Stupnu() + { + var g = Prehraj("md_sfdi.dxf"); + var cary = g.Vyplne.Where(v => !v.JeSolid).SelectMany(v => v.Cary!).ToList(); + Assert.NotEmpty(cary); + + foreach (var u in cary) + { + double dx = u.X2 - u.X1, dy = u.Y2 - u.Y1; + if (Math.Abs(dx) + Math.Abs(dy) < 1e-9) continue; + // ANSI31 je 45° (v tomhle logu 45,007° – tak to má zapsané zdroj). + double uhel = Math.Atan2(dy, dx) * 180 / Math.PI; + Assert.InRange(uhel, 44.9, 45.1); + } + } + + [Fact] + public void PlnaSrafaZustavaPlna() + { + // Ostatní loga mají výhradně SOLID – nesmí se z nich stát čárová šrafa. + var g = Prehraj("exprojekt.dxf"); + Assert.NotEmpty(g.Vyplne); + Assert.All(g.Vyplne, v => + { + Assert.True(v.JeSolid); + Assert.Null(v.Cary); + }); + } + + [Fact] + public void VychoziVyplnJePlna() + { + // Pojistka: `default(Vypln)` musí být plná výplň, jinak by volání Fill(loops) bez + // druhého parametru přestalo cokoli kreslit. + Assert.True(default(Vypln).JeSolid); + } + + [Fact] + public void BezDefiniceVzoruSeNevrationZadneCary() + { + var ctverec = new[] + { + new Contour(new[] + { + Edge.Line(0, 0, 10, 0), Edge.Line(10, 0, 10, 10), + Edge.Line(10, 10, 0, 10), Edge.Line(0, 10, 0, 0), + }, Closed: true), + }; + Assert.Empty(SrafaVzor.Cary(null, ctverec)); + } + + private static (double MinX, double MinY, double MaxX, double MaxY) Bbox( + IReadOnlyList> smycky) + { + double minX = double.MaxValue, minY = double.MaxValue, maxX = double.MinValue, maxY = double.MinValue; + foreach (var s in smycky) + foreach (var (x, y) in s) + { + if (x < minX) minX = x; + if (y < minY) minY = y; + if (x > maxX) maxX = x; + if (y > maxY) maxY = y; + } + return (minX, minY, maxX, maxY); + } +} diff --git a/Rozpisky/Dxf/DxfTemplate.cs b/Rozpisky/Dxf/DxfTemplate.cs index 4435b49..270e004 100644 --- a/Rozpisky/Dxf/DxfTemplate.cs +++ b/Rozpisky/Dxf/DxfTemplate.cs @@ -128,7 +128,7 @@ public sealed partial class DxfTemplate case Hatch hatch: { var loops = HatchSmycky(hatch); - if (loops.Count > 0) g.Fill(loops); + if (loops.Count > 0) g.Fill(loops, VyplnZ(hatch, loops)); break; } @@ -242,6 +242,21 @@ public sealed partial class DxfTemplate return (plain, wf); } + /// Druh výplně pro HATCH. Plná (SOLID) projde beze změny; u vzoru se vzor rozvine + /// na čáry pro rastrové renderery, ale jméno vzoru, měřítko a úhel se nesou dál, aby DXF + /// export zapsal zase editovatelnou šrafu. Když se vzor rozvinout nepodaří, plocha zůstane + /// prázdná – dřív se v takovém případě kreslila plná výplň a z logotypu byl černý blok. + private static Vypln VyplnZ(Hatch hatch, IReadOnlyList smycky) + { + string vzor = hatch.Pattern?.Name ?? "SOLID"; + if (hatch.IsSolid || string.IsNullOrWhiteSpace(vzor) + || vzor.Equals("SOLID", StringComparison.OrdinalIgnoreCase)) + return default; + + return Vypln.Vzorovana(vzor, hatch.PatternScale, hatch.PatternAngle * RadToDeg, + SrafaVzor.Cary(hatch.Pattern, smycky)); + } + /// Popis hladiny pro kreslítko – celý, ať se dá ve výstupu postavit znovu /// (barva z tabulky hladin, tloušťka, typ čáry, příznak tisku). private static HladinaStav HladinaZ(ACadSharp.Tables.Layer? layer) diff --git a/Rozpisky/Dxf/SrafaVzor.cs b/Rozpisky/Dxf/SrafaVzor.cs new file mode 100644 index 0000000..22f53d6 --- /dev/null +++ b/Rozpisky/Dxf/SrafaVzor.cs @@ -0,0 +1,181 @@ +using ACadSharp.Entities; +using Rozpisky.Rendering; + +namespace Rozpisky.Dxf; + +/// +/// Rozvine vzor šrafy (ANSI31 a spol.) na čáry. Rastrové renderery vzory neumí – bez tohohle +/// se každá šrafa kreslila jako plná výplň a z logotypu md_sfdi.dxf byl černý blok. +/// Do DXF se zapisuje původní HATCH se vzorem, tyhle čáry jsou jen pro kresbu. +/// +internal static class SrafaVzor +{ + /// Strop počtu čar na jednu šrafu. Vadné měřítko ve zdroji (nebo šrafa přes celý + /// výkres) by jinak vygenerovalo statisíce úseček a náhled by zamrzl. + private const int MaxCar = 20_000; + + /// Jemnost, s jakou se hranice šrafy zplošťují na mnohoúhelník (mm). + private const double Sagitta = 0.02; + + /// Čáry vzoru uvnitř zadaných smyček. Prázdné, když vzor není použitelný – + /// volající pak plochu nechá bez výplně (lepší než ji zaplácnout načerno). + public static IReadOnlyList Cary(HatchPattern? vzor, IReadOnlyList smycky) + { + var vysledek = new List(); + if (vzor?.Lines is not { Count: > 0 }) return vysledek; + + var polygony = Zplosti(smycky); + if (polygony.Count == 0) return vysledek; + + var (minX, minY, maxX, maxY) = Rozsah(polygony); + if (maxX - minX <= 0 || maxY - minY <= 0) return vysledek; + + foreach (var cara in vzor.Lines) + { + PridejCaryVzoru(cara, polygony, minX, minY, maxX, maxY, vysledek); + if (vysledek.Count >= MaxCar) break; + } + return vysledek; + } + + /// Jedna definiční čára vzoru = celá rodina rovnoběžek. Posun mezi sousedními + /// rovnoběžkami je vektor Offset ve světových souřadnicích: jeho kolmá složka určuje + /// rozteč, podélná posouvá čárkování. U ANSI31 vychází přesně kolmý na směr 45°. + private static void PridejCaryVzoru( + HatchPattern.Line cara, List> polygony, + double minX, double minY, double maxX, double maxY, List vysledek) + { + double dx = Math.Cos(cara.Angle), dy = Math.Sin(cara.Angle); // směr čáry + double nx = -dy, ny = dx; // kolmice + + double ox = cara.Offset.X, oy = cara.Offset.Y; + double kolma = ox * nx + oy * ny; // rozteč rovnoběžek + if (Math.Abs(kolma) < 1e-9) return; // rovnoběžky by splynuly + + // Kolik kroků pokryje bbox: promítni jeho rohy na kolmici. + double zaklad = cara.BasePoint.X * nx + cara.BasePoint.Y * ny; + var projekce = new[] + { + minX * nx + minY * ny, maxX * nx + minY * ny, + minX * nx + maxY * ny, maxX * nx + maxY * ny, + }; + double kOd = (projekce.Min() - zaklad) / kolma; + double kDo = (projekce.Max() - zaklad) / kolma; + if (kOd > kDo) (kOd, kDo) = (kDo, kOd); + + int od = (int)Math.Floor(kOd) - 1, doK = (int)Math.Ceiling(kDo) + 1; + if ((long)doK - od > MaxCar) return; // nesmyslně husté měřítko + + var carky = Carky(cara.DashLengths); + + for (int k = od; k <= doK; k++) + { + double px = cara.BasePoint.X + k * ox, py = cara.BasePoint.Y + k * oy; + foreach (var (t1, t2) in Uvnitr(polygony, px, py, dx, dy)) + foreach (var (u1, u2) in Orez(carky, t1, t2)) + { + vysledek.Add(new Usecka(px + dx * u1, py + dy * u1, px + dx * u2, py + dy * u2)); + if (vysledek.Count >= MaxCar) return; + } + } + } + + /// Úseky přímky (parametr t od bodu p ve směru d), které leží + /// uvnitř oblasti. Sudo-lichá parita jako u výplně: mezi 1. a 2. průsečíkem je uvnitř, + /// mezi 2. a 3. venku – díky tomu vyjdou i díry ve vnořených smyčkách. + private static List<(double Od, double Do)> Uvnitr( + List> polygony, double px, double py, double dx, double dy) + { + var pruseciky = new List(); + foreach (var polygon in polygony) + for (int i = 0; i < polygon.Count; i++) + { + var a = polygon[i]; + var b = polygon[(i + 1) % polygon.Count]; + // Znaménko kolmé vzdálenosti vrcholu od přímky. + double sa = (a.X - px) * -dy + (a.Y - py) * dx; + double sb = (b.X - px) * -dy + (b.Y - py) * dx; + if (sa == sb) continue; // hrana rovnoběžná s přímkou + double podil = sa / (sa - sb); + // Polouzavřený interval: vrchol sdílený dvěma hranami se započítá jen jednou, + // jinak by parita na něm přeskočila a zbytek řádku vyšel obráceně. + if (podil <= 0 || podil > 1) continue; + double x = a.X + (b.X - a.X) * podil, y = a.Y + (b.Y - a.Y) * podil; + pruseciky.Add((x - px) * dx + (y - py) * dy); + } + + pruseciky.Sort(); + var useky = new List<(double, double)>(); + for (int i = 0; i + 1 < pruseciky.Count; i += 2) + if (pruseciky[i + 1] - pruseciky[i] > 1e-9) + useky.Add((pruseciky[i], pruseciky[i + 1])); + return useky; + } + + /// Čárkování jedné periody: intervaly, které se kreslí. null = plná čára. + /// Nula v definici znamená tečku – kreslí se jako nepatrný úsek. + private static (double Perioda, List<(double Od, double Do)> Kresli)? Carky(IEnumerable? dashe) + { + var delky = dashe?.ToList(); + if (delky is not { Count: > 0 }) return null; + + double perioda = delky.Sum(Math.Abs); + if (perioda <= 1e-9) return null; + + var kresli = new List<(double, double)>(); + double t = 0; + foreach (var d in delky) + { + double l = Math.Abs(d) < 1e-9 ? 1e-6 : Math.Abs(d); + if (d >= 0) kresli.Add((t, t + l)); + t += l; + } + return kresli.Count == 0 ? null : (perioda, kresli); + } + + /// Ořeže úsek přímky čárkováním. Bez čárkování projde beze změny. + private static IEnumerable<(double Od, double Do)> Orez( + (double Perioda, List<(double Od, double Do)> Kresli)? carky, double t1, double t2) + { + if (carky is not { } c) { yield return (t1, t2); yield break; } + + long prvni = (long)Math.Floor(t1 / c.Perioda); + long posledni = (long)Math.Ceiling(t2 / c.Perioda); + if (posledni - prvni > MaxCar) { yield return (t1, t2); yield break; } // nesmyslně krátká perioda + + for (long i = prvni; i <= posledni; i++) + foreach (var (od, doT) in c.Kresli) + { + double a = Math.Max(t1, i * c.Perioda + od); + double b = Math.Min(t2, i * c.Perioda + doT); + if (b - a > 1e-9) yield return (a, b); + } + } + + private static List> Zplosti(IReadOnlyList smycky) + { + var vysledek = new List>(); + foreach (var smycka in smycky) + { + var body = new List<(double X, double Y)>(); + foreach (var hrana in smycka.Edges) hrana.AppendPoints(body, Sagitta); + if (body.Count >= 3) vysledek.Add(body); + } + return vysledek; + } + + private static (double MinX, double MinY, double MaxX, double MaxY) Rozsah( + List> polygony) + { + double minX = double.MaxValue, minY = double.MaxValue, maxX = double.MinValue, maxY = double.MinValue; + foreach (var polygon in polygony) + foreach (var (x, y) in polygon) + { + if (x < minX) minX = x; + if (y < minY) minY = y; + if (x > maxX) maxX = x; + if (y > maxY) maxY = y; + } + return (minX, minY, maxX, maxY); + } +} diff --git a/Rozpisky/Rendering/BoundsRenderer.cs b/Rozpisky/Rendering/BoundsRenderer.cs index b06bf46..bf80ae5 100644 --- a/Rozpisky/Rendering/BoundsRenderer.cs +++ b/Rozpisky/Rendering/BoundsRenderer.cs @@ -55,7 +55,7 @@ public sealed class BoundsRenderer : IProfileRenderer Add(x + dx + w, y + dy + blockH); } - public void Fill(IReadOnlyList loops) + public void Fill(IReadOnlyList loops, Vypln vypln = default) { foreach (var loop in loops) AddContour(loop); } diff --git a/Rozpisky/Rendering/DxfProfileRenderer.cs b/Rozpisky/Rendering/DxfProfileRenderer.cs index fb228d1..d70c4c9 100644 --- a/Rozpisky/Rendering/DxfProfileRenderer.cs +++ b/Rozpisky/Rendering/DxfProfileRenderer.cs @@ -154,12 +154,20 @@ public sealed class DxfProfileRenderer : IProfileRenderer } } - public void Fill(IReadOnlyList loops) + public void Fill(IReadOnlyList loops, Vypln vypln = default) { if (loops.Count == 0) return; try { - var hatch = new Hatch { IsSolid = true, Pattern = new HatchPattern("SOLID") }; + // Vzor šrafy se zapisuje zpátky jménem, měřítkem a úhlem – v CADu tak zůstane + // editovatelný. Rozvinuté čáry (Vypln.Cary) jsou jen pro rastrové renderery. + var hatch = new Hatch + { + IsSolid = vypln.JeSolid, + Pattern = new HatchPattern(vypln.JeSolid ? "SOLID" : vypln.Vzor!), + PatternScale = vypln.JeSolid || vypln.Meritko <= 0 ? 1 : vypln.Meritko, + PatternAngle = vypln.JeSolid ? 0 : vypln.UhelDeg * Math.PI / 180.0, + }; foreach (var loop in loops) { if (loop.Edges.Count == 0) continue; diff --git a/Rozpisky/Rendering/IProfileRenderer.cs b/Rozpisky/Rendering/IProfileRenderer.cs index 6994fec..a089e6b 100644 --- a/Rozpisky/Rendering/IProfileRenderer.cs +++ b/Rozpisky/Rendering/IProfileRenderer.cs @@ -53,6 +53,28 @@ public readonly record struct Edge( public (double X, double Y) PointAt(double theta) => (Cx + R * Math.Cos(theta), Cy + R * Math.Sin(theta)); } +/// Úsečka ve výkresových mm – čára vzoru šrafy. +public readonly record struct Usecka(double X1, double Y1, double X2, double Y2); + +/// +/// Druh výplně plochy. default = plná výplň (SOLID). +/// Nesolidní šrafa (ANSI31 apod.) se rastrovým rendererům předává už rozvinutá na +/// , protože Skia vzory šraf neumí; DXF export naopak +/// ignoruje a zapíše zpátky HATCH s původním jménem vzoru, měřítkem a úhlem, aby zůstal +/// v CADu editovatelný. Dřív se každá šrafa kreslila jako plná výplň – z logotypu +/// md_sfdi.dxf byl černý blok. +/// +public readonly record struct Vypln( + string? Vzor = null, double Meritko = 1, double UhelDeg = 0, IReadOnlyList? Cary = null) +{ + /// Plná výplň – buď se vzor neuvádí (default), nebo je to SOLID. + public bool JeSolid => string.IsNullOrEmpty(Vzor) || Vzor.Equals("SOLID", StringComparison.OrdinalIgnoreCase); + + /// Šrafa se vzorem, už rozvinutá na čáry pro rastrové renderery. + public static Vypln Vzorovana(string vzor, double meritko, double uhelDeg, IReadOnlyList cary) + => new(vzor, meritko, uhelDeg, cary); +} + /// Obrys = zřetězené hrany (konec jedné = počátek další). = true → /// vyplnitelná/uzavřená smyčka (poslední hrana se vrací k počátku prvního). public readonly record struct Contour(IReadOnlyList Edges, bool Closed); @@ -111,6 +133,7 @@ public interface IProfileRenderer /// Vyplní oblast danou jednou či více uzavřenými obrysy (even-odd → vnořené smyčky /// tvoří díry). Obrysy nesou analytické oblouky (viz ), takže výplně z - /// DXF HATCH – např. loga – mají hladké okraje v každém přiblížení i v PDF. - void Fill(IReadOnlyList loops); + /// DXF HATCH – např. loga – mají hladké okraje v každém přiblížení i v PDF. + /// říká, čím se plocha vyplní; default = plná. + void Fill(IReadOnlyList loops, Vypln vypln = default); } diff --git a/Rozpisky/Rendering/SkiaProfileRenderer.cs b/Rozpisky/Rendering/SkiaProfileRenderer.cs index d33f52e..e27fec2 100644 --- a/Rozpisky/Rendering/SkiaProfileRenderer.cs +++ b/Rozpisky/Rendering/SkiaProfileRenderer.cs @@ -261,9 +261,21 @@ public sealed class SkiaProfileRenderer : IProfileRenderer, IDisposable return lines; } - public void Fill(IReadOnlyList loops) + public void Fill(IReadOnlyList loops, Vypln vypln = default) { if (loops.Count == 0) return; + + // Šrafa se vzorem (ANSI31 apod.): Skia vzory neumí, kreslí se rozvinuté čáry. Když se + // vzor rozvinout nepodařilo, zůstane plocha prázdná – zaplácnout ji načerno by bylo horší. + if (!vypln.JeSolid) + { + if (vypln.Cary is not { Count: > 0 }) return; + _stroke.StrokeWidth = StrokePx; + foreach (var u in vypln.Cary) + _canvas.DrawLine(_map(u.X1, u.Y1), _map(u.X2, u.Y2), _stroke); + return; + } + using var path = new SKPath { FillType = SKPathFillType.EvenOdd }; bool any = false; foreach (var loop in loops) diff --git a/Rozpisky/Rendering/TransformRenderer.cs b/Rozpisky/Rendering/TransformRenderer.cs index 9514eb5..e7e6d5a 100644 --- a/Rozpisky/Rendering/TransformRenderer.cs +++ b/Rozpisky/Rendering/TransformRenderer.cs @@ -54,6 +54,10 @@ public sealed class TransformRenderer : IProfileRenderer TextStyle style = default, double widthFactor = 1, double maxWidthMm = 0) => _inner.Text(X(x), Y(y), text, heightMm * _s, h, v, rotationDeg, style, widthFactor, maxWidthMm * _s); - public void Fill(IReadOnlyList loops) - => _inner.Fill(loops.Select(c => new Contour(c.Edges.Select(T).ToList(), c.Closed)).ToList()); + public void Fill(IReadOnlyList loops, Vypln vypln = default) + => _inner.Fill( + loops.Select(c => new Contour(c.Edges.Select(T).ToList(), c.Closed)).ToList(), + vypln.Cary is null + ? vypln + : vypln with { Cary = vypln.Cary.Select(u => new Usecka(X(u.X1), Y(u.Y1), X(u.X2), Y(u.Y2))).ToList() }); }