Šrafy: rozvinout vzor na čáry místo plné výplně

Každá šrafa se kreslila jako plná výplň, bez ohledu na vzor. Logo md_sfdi.dxf
má 3× ANSI31 (šikmé čáry) – vycházel z něj plný blok. Nový SrafaVzor rozvine
definiční čáry vzoru na úsečky: rodina rovnoběžek podle vektoru Offset,
ořezaná hranicemi šrafy sudo-lichou paritou (takže díry ve vnořených smyčkách
vyjdou správně) a čárkováním z DashLengths. Proti zamrznutí náhledu při vadném
měřítku ve zdroji je strop 20 000 úseček na šrafu.

Rozhraní: Fill(loops) dostává druhý parametr Vypln. Rastrové renderery kreslí
rozvinuté čáry (Skia vzory neumí), DXF export je ignoruje a zapíše zpátky HATCH
s původním jménem vzoru, měřítkem a úhlem – v CADu tak zůstane editovatelný.
Když se vzor rozvinout nepodaří, plocha zůstane prázdná; zaplácnout ji načerno
je horší než ji nevyplnit.

Ověřeno i vizuálně: z 9 dodávaných log se změnilo jen md_sfdi, ostatních 8 je
po renderu do PNG pixel po pixelu shodných s předchozím stavem.

105 testů zelených.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-07 14:36:51 +02:00
co-authored by Claude Opus 5
parent 95003ce835
commit 001afc2d81
9 changed files with 376 additions and 10 deletions
+5 -1
View File
@@ -50,9 +50,13 @@ public sealed class RecordingRenderer : IProfileRenderer
TextStyle style = default, double widthFactor = 1, double maxWidthMm = 0) TextStyle style = default, double widthFactor = 1, double maxWidthMm = 0)
=> Texts.Add(new TextCall(x, y, text, heightMm, h, v, rotationDeg, style, widthFactor, maxWidthMm)); => Texts.Add(new TextCall(x, y, text, heightMm, h, v, rotationDeg, style, widthFactor, maxWidthMm));
public void Fill(IReadOnlyList<Contour> loops) /// <summary>Druh výplně u jednotlivých volání Fill (plná / vzor a jeho čáry).</summary>
public List<Vypln> Vyplne { get; } = new();
public void Fill(IReadOnlyList<Contour> loops, Vypln vypln = default)
{ {
FillContours.Add(loops); FillContours.Add(loops);
Vyplne.Add(vypln);
Fills.Add(loops.Select(Zplosti).ToList()); Fills.Add(loops.Select(Zplosti).ToList());
} }
+119
View File
@@ -0,0 +1,119 @@
using Rozpisky.Dxf;
using Rozpisky.Rendering;
using Xunit;
namespace Rozpisky.Tests;
/// <summary>
/// Šrafy se vzorem. Logo <c>md_sfdi.dxf</c> 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.
/// </summary>
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<IReadOnlyList<(double X, double Y)>> 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);
}
}
+16 -1
View File
@@ -128,7 +128,7 @@ public sealed partial class DxfTemplate
case Hatch hatch: case Hatch hatch:
{ {
var loops = HatchSmycky(hatch); var loops = HatchSmycky(hatch);
if (loops.Count > 0) g.Fill(loops); if (loops.Count > 0) g.Fill(loops, VyplnZ(hatch, loops));
break; break;
} }
@@ -242,6 +242,21 @@ public sealed partial class DxfTemplate
return (plain, wf); return (plain, wf);
} }
/// <summary>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.</summary>
private static Vypln VyplnZ(Hatch hatch, IReadOnlyList<Contour> 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));
}
/// <summary>Popis hladiny pro kreslítko celý, ať se dá ve výstupu postavit znovu /// <summary>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).</summary> /// (barva z tabulky hladin, tloušťka, typ čáry, příznak tisku).</summary>
private static HladinaStav HladinaZ(ACadSharp.Tables.Layer? layer) private static HladinaStav HladinaZ(ACadSharp.Tables.Layer? layer)
+181
View File
@@ -0,0 +1,181 @@
using ACadSharp.Entities;
using Rozpisky.Rendering;
namespace Rozpisky.Dxf;
/// <summary>
/// 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 <c>md_sfdi.dxf</c> byl černý blok.
/// Do DXF se zapisuje původní <c>HATCH</c> se vzorem, tyhle čáry jsou jen pro kresbu.
/// </summary>
internal static class SrafaVzor
{
/// <summary>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.</summary>
private const int MaxCar = 20_000;
/// <summary>Jemnost, s jakou se hranice šrafy zplošťují na mnohoúhelník (mm).</summary>
private const double Sagitta = 0.02;
/// <summary>Čá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).</summary>
public static IReadOnlyList<Usecka> Cary(HatchPattern? vzor, IReadOnlyList<Contour> smycky)
{
var vysledek = new List<Usecka>();
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;
}
/// <summary>Jedna definiční čára vzoru = celá rodina rovnoběžek. Posun mezi sousedními
/// rovnoběžkami je vektor <c>Offset</c> 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°.</summary>
private static void PridejCaryVzoru(
HatchPattern.Line cara, List<List<(double X, double Y)>> polygony,
double minX, double minY, double maxX, double maxY, List<Usecka> 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;
}
}
}
/// <summary>Úseky přímky (parametr <c>t</c> od bodu <c>p</c> ve směru <c>d</c>), 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.</summary>
private static List<(double Od, double Do)> Uvnitr(
List<List<(double X, double Y)>> polygony, double px, double py, double dx, double dy)
{
var pruseciky = new List<double>();
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;
}
/// <summary>Čárkování jedné periody: intervaly, které se kreslí. <c>null</c> = plná čára.
/// Nula v definici znamená tečku kreslí se jako nepatrný úsek.</summary>
private static (double Perioda, List<(double Od, double Do)> Kresli)? Carky(IEnumerable<double>? 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);
}
/// <summary>Ořeže úsek přímky čárkováním. Bez čárkování projde beze změny.</summary>
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<List<(double X, double Y)>> Zplosti(IReadOnlyList<Contour> smycky)
{
var vysledek = new List<List<(double X, double Y)>>();
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<List<(double X, double Y)>> 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);
}
}
+1 -1
View File
@@ -55,7 +55,7 @@ public sealed class BoundsRenderer : IProfileRenderer
Add(x + dx + w, y + dy + blockH); Add(x + dx + w, y + dy + blockH);
} }
public void Fill(IReadOnlyList<Contour> loops) public void Fill(IReadOnlyList<Contour> loops, Vypln vypln = default)
{ {
foreach (var loop in loops) AddContour(loop); foreach (var loop in loops) AddContour(loop);
} }
+10 -2
View File
@@ -154,12 +154,20 @@ public sealed class DxfProfileRenderer : IProfileRenderer
} }
} }
public void Fill(IReadOnlyList<Contour> loops) public void Fill(IReadOnlyList<Contour> loops, Vypln vypln = default)
{ {
if (loops.Count == 0) return; if (loops.Count == 0) return;
try 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) foreach (var loop in loops)
{ {
if (loop.Edges.Count == 0) continue; if (loop.Edges.Count == 0) continue;
+25 -2
View File
@@ -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)); public (double X, double Y) PointAt(double theta) => (Cx + R * Math.Cos(theta), Cy + R * Math.Sin(theta));
} }
/// <summary>Úsečka ve výkresových mm čára vzoru šrafy.</summary>
public readonly record struct Usecka(double X1, double Y1, double X2, double Y2);
/// <summary>
/// Druh výplně plochy. <c>default</c> = plná výplň (SOLID).
/// <para>Nesolidní šrafa (ANSI31 apod.) se rastrovým rendererům předává už rozvinutá na
/// <paramref name="Cary"/>, protože Skia vzory šraf neumí; DXF export naopak <paramref name="Cary"/>
/// ignoruje a zapíše zpátky <c>HATCH</c> 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
/// <c>md_sfdi.dxf</c> byl černý blok.</para>
/// </summary>
public readonly record struct Vypln(
string? Vzor = null, double Meritko = 1, double UhelDeg = 0, IReadOnlyList<Usecka>? Cary = null)
{
/// <summary>Plná výplň buď se vzor neuvádí (<c>default</c>), nebo je to SOLID.</summary>
public bool JeSolid => string.IsNullOrEmpty(Vzor) || Vzor.Equals("SOLID", StringComparison.OrdinalIgnoreCase);
/// <summary>Šrafa se vzorem, už rozvinutá na čáry pro rastrové renderery.</summary>
public static Vypln Vzorovana(string vzor, double meritko, double uhelDeg, IReadOnlyList<Usecka> cary)
=> new(vzor, meritko, uhelDeg, cary);
}
/// <summary>Obrys = zřetězené hrany (konec jedné = počátek další). <paramref name="Closed"/> = true → /// <summary>Obrys = zřetězené hrany (konec jedné = počátek další). <paramref name="Closed"/> = true →
/// vyplnitelná/uzavřená smyčka (poslední hrana se vrací k počátku prvního).</summary> /// vyplnitelná/uzavřená smyčka (poslední hrana se vrací k počátku prvního).</summary>
public readonly record struct Contour(IReadOnlyList<Edge> Edges, bool Closed); public readonly record struct Contour(IReadOnlyList<Edge> Edges, bool Closed);
@@ -111,6 +133,7 @@ public interface IProfileRenderer
/// <summary>Vyplní oblast danou jednou či více uzavřenými obrysy (even-odd → vnořené smyčky /// <summary>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 <see cref="Contour"/>), takže výplně z /// tvoří díry). Obrysy nesou analytické oblouky (viz <see cref="Contour"/>), takže výplně z
/// DXF HATCH např. loga mají hladké okraje v každém přiblížení i v PDF.</summary> /// DXF HATCH např. loga mají hladké okraje v každém přiblížení i v PDF.
void Fill(IReadOnlyList<Contour> loops); /// <paramref name="vypln"/> říká, čím se plocha vyplní; <c>default</c> = plná.</summary>
void Fill(IReadOnlyList<Contour> loops, Vypln vypln = default);
} }
+13 -1
View File
@@ -261,9 +261,21 @@ public sealed class SkiaProfileRenderer : IProfileRenderer, IDisposable
return lines; return lines;
} }
public void Fill(IReadOnlyList<Contour> loops) public void Fill(IReadOnlyList<Contour> loops, Vypln vypln = default)
{ {
if (loops.Count == 0) return; 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 }; using var path = new SKPath { FillType = SKPathFillType.EvenOdd };
bool any = false; bool any = false;
foreach (var loop in loops) foreach (var loop in loops)
+6 -2
View File
@@ -54,6 +54,10 @@ public sealed class TransformRenderer : IProfileRenderer
TextStyle style = default, double widthFactor = 1, double maxWidthMm = 0) 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); => _inner.Text(X(x), Y(y), text, heightMm * _s, h, v, rotationDeg, style, widthFactor, maxWidthMm * _s);
public void Fill(IReadOnlyList<Contour> loops) public void Fill(IReadOnlyList<Contour> loops, Vypln vypln = default)
=> _inner.Fill(loops.Select(c => new Contour(c.Edges.Select(T).ToList(), c.Closed)).ToList()); => _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() });
} }