Baseline: současný stav projektu Rozpisky

První commit — WPF aplikace pro generování výkresových rohových razítek
(.NET 10, SkiaSharp, ACadSharp, ClosedXML) včetně xUnit testů, DXF šablony,
log a dokumentace. Build výstupy (bin/, obj/, .vs/) jsou ignorovány.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-07 09:45:49 +02:00
co-authored by Claude Opus 5
commit a6377ab583
107 changed files with 349485 additions and 0 deletions
+55
View File
@@ -0,0 +1,55 @@
using Rozpisky.Dxf;
using Rozpisky.Rendering;
using Xunit;
namespace Rozpisky.Tests;
/// <summary>
/// Bbox pro fit fragmentu (loga do placeholderu): dřív se text počítal jen jako vkládací bod,
/// takže textová loga mohla přetéct rámeček. Teď se bbox počítá replayem do BoundsRenderer.
/// </summary>
public class BoundsTesty
{
[Fact]
public void VsechnaLoga_MajiNeprazdneBounds()
{
foreach (var name in TestData.VsechnaLoga)
{
var tpl = new DxfTemplate(TestData.Logo(name));
Assert.True(tpl.Bounds is { W: > 0, H: > 0 }, $"{name}: Bounds = {tpl.Bounds}");
}
}
[Fact]
public void Bounds_ZahrnujiTextIVyplne()
{
// sprava_zeleznic: 2 texty + 2 SOLID šrafy. Bbox musí obsáhnout všechny smyčky výplní
// a být širší než samotné výplně (texty SPRÁVA/ŽELEZNIC vedle značky).
var tpl = new DxfTemplate(TestData.Logo("sprava_zeleznic.dxf"));
var rec = new RecordingRenderer();
tpl.Replay(rec);
var body = rec.Fills.SelectMany(f => f).SelectMany(l => l).ToList();
Assert.NotEmpty(body);
var b = tpl.Bounds!.Value;
Assert.True(body.All(p => p.X >= b.X - 1e-6 && p.X <= b.X + b.W + 1e-6
&& p.Y >= b.Y - 1e-6 && p.Y <= b.Y + b.H + 1e-6));
double vyplneW = body.Max(p => p.X) - body.Min(p => p.X);
Assert.True(b.W > vyplneW, $"bbox W={b.W:F2} nezahrnuje texty (výplně W={vyplneW:F2})");
}
[Fact]
public void BoundsRenderer_RespektujeZarovnaniTextu()
{
// Center/Right roste od kotvy doleva; Top dolů dřív box rostl vždy jen doprava nahoru.
var stred = new BoundsRenderer();
stred.Text(0, 0, "ABCD", 10, HAlign.Center, VAlign.Middle);
Assert.True(stred.MinX < 0 && stred.MaxX > 0, $"Center: {stred.MinX}..{stred.MaxX}");
Assert.True(stred.MinY < 0 && stred.MaxY > 0, $"Middle: {stred.MinY}..{stred.MaxY}");
var vpravo = new BoundsRenderer();
vpravo.Text(0, 0, "ABCD", 10, HAlign.Right, VAlign.Top);
Assert.True(vpravo.MinX < 0 && vpravo.MaxX <= 1e-9, $"Right: {vpravo.MinX}..{vpravo.MaxX}");
Assert.True(vpravo.MinY < 0 && vpravo.MaxY <= 1e-9, $"Top: {vpravo.MinY}..{vpravo.MaxY}");
}
}
+96
View File
@@ -0,0 +1,96 @@
using Rozpisky.Dxf;
using Xunit;
namespace Rozpisky.Tests;
/// <summary>
/// Šrafy (HATCH) s hranovými hranicemi (úsečky, oblouky, elipsy) dřív se tiše přeskočily,
/// takže tečky a oblé výplně v logách chyběly. Hodnoty jsou předpočítané ze skutečných DXF.
/// </summary>
public class HatchTesty
{
private static RecordingRenderer Prehraj(string logo)
{
var rec = new RecordingRenderer();
new DxfTemplate(TestData.Logo(logo)).Replay(rec);
return rec;
}
private static (double W, double H, double Cx, double Cy) Bbox(IReadOnlyList<(double X, double Y)> loop)
{
double ax = double.MaxValue, ay = double.MaxValue, bx = double.MinValue, by = double.MinValue;
foreach (var (x, y) in loop)
{
ax = Math.Min(ax, x); ay = Math.Min(ay, y);
bx = Math.Max(bx, x); by = Math.Max(by, y);
}
return (bx - ax, by - ay, (ax + bx) / 2, (ay + by) / 2);
}
[Fact]
public void Tesia_TeckaZKruhoveHrany_SeVyplni()
{
var rec = Prehraj("tesia.dxf");
// 2 SOLID hatche: obrys žárovky (polyline s bulge) + tečka (plná kruhová hrana r≈0,78).
var smycky = rec.Fills.SelectMany(f => f).ToList();
Assert.Equal(2, smycky.Count);
// Tečka = menší smyčka; průměr ≈ 1,56 mm (plný kruh z jediné obloukové hrany).
var tecka = smycky.OrderBy(l => Bbox(l).W).First();
var (w, h, _, _) = Bbox(tecka);
Assert.InRange(w, 1.50, 1.62);
Assert.InRange(h, 1.50, 1.62);
}
[Fact]
public void SudopBrno_MichaneUseckoveAObloukoveHrany_DajiUzavreneSmycky()
{
var rec = Prehraj("sudopBrno.dxf");
// 6 SOLID šraf, všechny jen s hranovými cestami (úsečky + oblouky) → dřív 0 výplní.
// Referenční bboxy spočtené nezávisle hustým vzorkováním raw dat hran (512 dílků/oblouk).
Assert.Equal(6, rec.Fills.Count);
var smycky = rec.Fills.SelectMany(f => f).ToList();
Assert.Equal(7, smycky.Count); // jeden hatch má 2 cesty, ostatní po jedné
Assert.All(smycky, l => Assert.True(l.Count >= 3));
// Nezávisle na pořadí (draw order přes SortEntitiesTable může hatche přeuspořádat) ověříme,
// že existují smyčky s referenčními bboxy včetně plné kružnice r=3,825 (7,649²) z jediné
// obloukové hrany a řetězů úsečka+oblouk vč. ccw=false (zrcadlené úhly). Chybné znaménko/
// otočení hrany nebo ztracená kružnice by referenci znatelně změnily.
MaSmyckuBbox(smycky, 7.649, 7.649);
MaSmyckuBbox(smycky, 5.571, 0.626);
MaSmyckuBbox(smycky, 2.092, 0.066);
MaSmyckuBbox(smycky, 7.807, 1.085);
MaSmyckuBbox(smycky, 11.290, 1.733);
}
private static void MaSmyckuBbox(IReadOnlyList<IReadOnlyList<(double X, double Y)>> smycky, double refW, double refH)
=> Assert.Contains(smycky, l =>
{
var (w, h, _, _) = Bbox(l);
return w >= refW - 0.1 && w <= refW + 0.02 && h >= refH - 0.1 && h <= refH + 0.02;
});
[Fact]
public void Signalprojekt_KruhovaAEliptickaHrana_SeVyplni()
{
var rec = Prehraj("signalprojekt.dxf");
Assert.Equal(2, rec.Fills.Count);
var smycky = rec.Fills.SelectMany(f => f).ToList();
// Eliptická cesta: poloosy 1,16 a 0,759·1,16 → bbox ≈ 1,76 × 2,32 (hlavní osa svisle).
Assert.Contains(smycky, l =>
{
var (w, h, _, _) = Bbox(l);
double lo = Math.Min(w, h), hi = Math.Max(w, h);
return Math.Abs(hi - 2.32) < 0.05 && Math.Abs(lo - 1.76) < 0.05;
});
}
[Fact]
public void PolylinioveHranice_BezRegrese()
{
// exprojekt: 3 SOLID šrafy čistě s polyline hranicemi musí zůstat beze změny.
var rec = Prehraj("exprojekt.dxf");
Assert.Equal(3, rec.Fills.Count);
Assert.All(rec.Fills, f => Assert.All(f, l => Assert.True(l.Count >= 3)));
}
}
+130
View File
@@ -0,0 +1,130 @@
using Rozpisky.Dxf;
using Rozpisky.Models;
using Xunit;
namespace Rozpisky.Tests;
/// <summary>
/// Čistá byznys logika převodu vstupů na SŽ-kódy a názvy: 47-poziční kód
/// (<see cref="KodPrilohy"/>), rozpad čísla objektu (<see cref="ObjektOznaceni"/>) a automatické
/// jméno rozvržení (<see cref="Priloha.JmenoRozvrzeniAuto"/>). Hlídá pevnou šířku/pozice kódu
/// tichý posun by jinak zkazil kód na každém výkresu. Očekávané hodnoty jsou worked examples
/// z doc-komentářů příslušných metod.
/// </summary>
public class KodovaniTesty
{
private static Projekt VzorProjekt() => new()
{
Skod = "SO12345",
StupenDokumentace = "DUSP",
};
private static Priloha VzorPriloha() => new()
{
CisloObjektu = "SO131.45.12.01",
OznaceniCasti = "D",
OznaceniDilciCasti = "D110",
TypPrilohy = "D1",
DilciPriloha = "1",
PoradiPrilohy = "001",
R1 = "V00",
};
[Fact]
public void Sestav_MaPevnychSedmActyricetZnaku_APozice()
{
var kod = KodPrilohy.Sestav(VzorProjekt(), VzorPriloha());
Assert.Equal(KodPrilohy.Delka, kod.Length);
Assert.Equal("SO12345XXXX_DUSP_D110_SO1314512_01_D1_1_001_V00", kod);
}
[Fact]
public void Sestav_PrazdnaPoleDoplniX_ProrezanaOrizne()
{
var p = new Projekt { Skod = "", StupenDokumentace = "" };
var r = new Priloha { CisloObjektu = "", R1 = "" };
var kod = KodPrilohy.Sestav(p, r);
Assert.Equal(KodPrilohy.Delka, kod.Length);
// Samé X kromě separátorů na pevných pozicích 12,17,22,32,35,38,40,44.
Assert.Equal("XXXXXXXXXXX_XXXX_XXXX_XXXXXXXXX_XX_XX_X_XXX_XXX", kod);
}
[Fact]
public void SestavDesky_MaPevnyStred_XX_X_000()
{
var kod = KodPrilohy.SestavDesky(VzorProjekt(), VzorPriloha());
Assert.Equal(KodPrilohy.Delka, kod.Length);
Assert.Equal("SO12345XXXX_DUSP_D110_SO1314512_01_XX_X_000_V00", kod);
}
[Fact]
public void SestavSeznam_PrepiseStredAVoltelnouRevizi()
{
var kod = KodPrilohy.SestavSeznam(VzorProjekt(), VzorPriloha(), revizeKod: "P01");
Assert.Equal(KodPrilohy.Delka, kod.Length);
Assert.Equal("SO12345XXXX_DUSP_D110_SO1314512_01_XX_X_001_P01", kod);
}
[Theory]
[InlineData("V00", 'V', "00")] // vedoucí znak = typ, zbytek číslo
[InlineData("002", '0', "02")] // číslicový typ 0, číslo z dalších číslic
[InlineData("", 'X', "XX")] // bez revize → X + XX
public void RozpadRevize_SePromitneDoPozic45Az47(string rev, char typ, string cislo)
{
var r = VzorPriloha();
r.R1 = rev;
var kod = KodPrilohy.Sestav(VzorProjekt(), r);
Assert.Equal(typ, kod[44]); // pozice 45 (0-based 44)
Assert.Equal(cislo, kod.Substring(45)); // pozice 4647
}
[Fact]
public void ObjektOznaceni_Rozpad_RozdeliNaSlozky()
{
var o = ObjektOznaceni.Rozpad("SO131.45.12.01");
Assert.Equal("SO", o.Prefix);
Assert.Equal("131", o.Cislo);
Assert.Equal("45", o.Usek);
Assert.Equal("12", o.Razeni);
Assert.Equal("01", o.Podobjekt);
}
[Theory]
[InlineData("SK11", "SK", "11")] // jen řada, bez dalších segmentů
[InlineData("PS 22-3_4", "PS", "")] // libovolný oddělovač; "PS" samé písmeno → prázdné číslo
public void ObjektOznaceni_Rozpad_ZvladneOddelovaceAKratkeVstupy(string vstup, string prefix, string cislo)
{
var o = ObjektOznaceni.Rozpad(vstup);
Assert.Equal(prefix, o.Prefix);
Assert.Equal(cislo, o.Cislo);
}
[Fact]
public void ObjektOznaceni_Rozpad_PrazdnyVstupDaSamePrazdne()
{
var o = ObjektOznaceni.Rozpad("");
Assert.Equal(new ObjektOznaceni("", "", "", "", ""), o);
}
[Fact]
public void JmenoRozvrzeniAuto_SkladaZObjektuAPrilohy()
{
var r = VzorPriloha();
Assert.Equal("SO1314512_01_D1_1_001", r.JmenoRozvrzeniAuto);
}
[Fact]
public void JmenoRozvrzeniAuto_ProDeskyJeObjektovySpecial()
{
var r = VzorPriloha();
r.JeDesky = true;
Assert.Equal("SO1314512_01_XX_X_000", r.JmenoRozvrzeniAuto);
}
[Fact]
public void NazevObjektovehoSpecialu_TypXXZustavaLiteralne()
{
Assert.Equal("SO1314512_01_XX_X_001",
Priloha.NazevObjektovehoSpecialu("SO131.45.12.01", "001"));
}
}
+60
View File
@@ -0,0 +1,60 @@
using System.IO;
using ACadSharp.Entities;
using ACadSharp.IO;
using Rozpisky.Dxf;
using Rozpisky.Rendering;
using Xunit;
namespace Rozpisky.Tests;
/// <summary>
/// Oblique úhel textu (kurzíva náklonem, DXF group 51/50) dřív se ignoroval a text
/// se kreslil vzpřímeně. „SUDOP BRNO" má v DXF group 51 = 15° a group 41 (width) = 0,7.
/// </summary>
public class ObliqueTesty
{
[Fact]
public void SudopBrno_TextNeseObliqueAWidthFactor()
{
var rec = new RecordingRenderer();
new DxfTemplate(TestData.Logo("sudopBrno.dxf")).Replay(rec);
var t = Assert.Single(rec.Texts);
Assert.Equal("SUDOP BRNO", t.Text);
Assert.Equal(15.0, t.Style.ObliqueDeg, 2); // DXF group 51 = 15.0
Assert.Equal(0.7, t.WidthFactor, 3);
}
[Fact]
public void OstatniLoga_MajiObliqueNula()
{
// Regrese: žádné jiné logo oblique nemá nesmí se objevit nenulový sklon.
foreach (var name in TestData.VsechnaLoga.Where(n => n != "sudopBrno.dxf"))
{
var rec = new RecordingRenderer();
new DxfTemplate(TestData.Logo(name)).Replay(rec);
Assert.All(rec.Texts, t => Assert.Equal(0, t.Style.ObliqueDeg));
}
}
[Fact]
public void DxfExport_ZachovaObliqueNaTextu()
{
var dxf = new DxfProfileRenderer();
dxf.Layer("0", 0x000000, 0.25);
dxf.Text(10, 20, "Zkouška", 2.5, style: new TextStyle("Arial", ObliqueDeg: 3));
string path = Path.Combine(Path.GetTempPath(), $"rozpisky-oblique-{Guid.NewGuid():N}.dxf");
try
{
DxfWriter.Write(path, dxf.Document, binary: false);
var doc = DxfReader.Read(path);
var t = Assert.Single(doc.Entities.OfType<TextEntity>());
Assert.Equal(3.0 * Math.PI / 180.0, t.ObliqueAngle, 6);
}
finally
{
File.Delete(path);
}
}
}
+138
View File
@@ -0,0 +1,138 @@
using System.IO;
using ACadSharp.Entities;
using ACadSharp.IO;
using Rozpisky.Dxf;
using Rozpisky.Rendering;
using Xunit;
namespace Rozpisky.Tests;
/// <summary>
/// Analytické oblouky end-to-end: model <see cref="Edge"/>/<see cref="Contour"/>, jemnost vzorkování
/// (sagitta) a round-trip přes DXF export (<see cref="DxfProfileRenderer"/>) + import
/// (<see cref="DxfTemplate"/>). Hlídá zejména znaménka (CCW/CW) a převod bulge → oblouk.
/// </summary>
public class ObloukyTesty
{
private static (double W, double H, double MinX, double MinY) Bbox(IEnumerable<(double X, double Y)> pts)
{
double ax = double.MaxValue, ay = double.MaxValue, bx = double.MinValue, by = double.MinValue;
foreach (var (x, y) in pts) { ax = Math.Min(ax, x); ay = Math.Min(ay, y); bx = Math.Max(bx, x); by = Math.Max(by, y); }
return (bx - ax, by - ay, ax, ay);
}
private static List<(double X, double Y)> Zplosti(Contour c, double tol = 0.005)
{
var pts = new List<(double X, double Y)>();
foreach (var e in c.Edges) e.AppendPoints(pts, tol);
if (c.Edges.Count > 0) pts.Add((c.Edges[^1].EX, c.Edges[^1].EY));
return pts;
}
[Fact]
public void Edge_PointAt_A_Reversed_SediKoncoveBody()
{
// Čtvrtkružnice jednotkového kruhu proti směru hodin z (1,0) do (0,1).
var e = Edge.Arc(1, 0, 0, 1, 0, 0, r: 1, a0: 0, sweep: Math.PI / 2);
var s = e.PointAt(e.A0);
var en = e.PointAt(e.A0 + e.Sweep);
Assert.Equal(1, s.X, 6); Assert.Equal(0, s.Y, 6);
Assert.Equal(0, en.X, 6); Assert.Equal(1, en.Y, 6);
var r = e.Reversed();
Assert.Equal(e.EX, r.SX, 9); Assert.Equal(e.SX, r.EX, 9);
Assert.Equal(Math.PI / 2, r.A0, 9);
Assert.Equal(-Math.PI / 2, r.Sweep, 9);
// Otočená hrana má stejný geometrický počátek jako původní konec.
var rs = r.PointAt(r.A0);
Assert.Equal(0, rs.X, 6); Assert.Equal(1, rs.Y, 6);
}
[Theory]
[InlineData(10.0, 0.02)]
[InlineData(3.825, 0.02)]
[InlineData(50.0, 0.05)]
public void ArcSegments_DodrziSagittu(double r, double tol)
{
double sweep = 2 * Math.PI;
int n = ContourGeometry.ArcSegments(sweep, r, tol);
double delta = sweep / n;
double sagitta = r * (1 - Math.Cos(delta / 2));
Assert.True(sagitta <= tol + 1e-9, $"sagitta {sagitta} > tol {tol} (n={n})");
}
[Fact]
public void AnalytickyKruh_ZplosteniDaSpravnyBbox()
{
// Jediná Arc hrana přes celý kruh (r=3,825) → bbox = průměr, ostrý na tětivovou toleranci.
var e = Edge.Arc(3.825, 0, 3.825, 0, 0, 0, r: 3.825, a0: 0, sweep: 2 * Math.PI);
var (w, h, _, _) = Bbox(Zplosti(new Contour(new[] { e }, Closed: true)));
Assert.InRange(w, 7.64, 7.65);
Assert.InRange(h, 7.64, 7.65);
}
[Theory]
[InlineData(1.0)] // CCW horní půlkruh
[InlineData(-1.0)] // CW dolní půlkruh hlídá normalizaci úhlů v exportu i importu
public void RoundTrip_HatchOblouk_ZachovaGeometrii(double smer)
{
double r = 5;
double sweep = smer * Math.PI;
// Půldisk: oblouk (5,0)→(-5,0) + úsečka zpět.
var arc = Edge.Arc(5, 0, -5, 0, 0, 0, r, a0: 0, sweep: sweep);
var line = Edge.Line(-5, 0, 5, 0);
var contour = new Contour(new[] { arc, line }, Closed: true);
var g = new DxfProfileRenderer();
g.Layer("0", 0x000000);
g.Fill(new[] { contour });
// Export → dočasný DXF → import zpět a porovnání bboxu (sagitta ≤ 0,02 mm).
string path = Path.Combine(Path.GetTempPath(), $"rozpisky_arc_{Guid.NewGuid():N}.dxf");
try
{
DxfWriter.Write(path, g.Document, binary: false);
var rec = new RecordingRenderer();
new DxfTemplate(path).Replay(rec);
var loop = Assert.Single(Assert.Single(rec.FillContours));
var (w, h, minX, minY) = Bbox(Zplosti(loop, 0.02));
Assert.InRange(w, 9.9, 10.05); // šířka = 2r
Assert.InRange(h, 4.95, 5.05); // výška = r
Assert.InRange(minX, -5.05, -4.95);
if (smer > 0) Assert.InRange(minY, -0.05, 0.05); // horní půlkruh: y ∈ [0, 5]
else Assert.InRange(minY, -5.05, -4.95); // dolní půlkruh: y ∈ [-5, 0]
}
finally { if (File.Exists(path)) File.Delete(path); }
}
[Theory]
[InlineData(1.0)]
[InlineData(-1.0)]
public void StandaloneArc_Export_DaEntituArcSpravnymUhlem(double smer)
{
double r = 4, a0 = 0.3, sweep = smer * 1.2;
var edge = Edge.Arc(0, 0, 0, 0, cx: 1, cy: 2, r: r, a0: a0, sweep: sweep);
// dopočti skutečné koncové body
var s = edge.PointAt(a0); var e = edge.PointAt(a0 + sweep);
var contour = new Contour(new[] { Edge.Arc(s.X, s.Y, e.X, e.Y, 1, 2, r, a0, sweep) }, Closed: false);
var g = new DxfProfileRenderer();
g.Layer("0", 0);
g.Stroke(contour);
var arc = Assert.Single(g.Document.Entities.OfType<Arc>());
Assert.Equal(1, arc.Center.X, 6);
Assert.Equal(2, arc.Center.Y, 6);
Assert.Equal(r, arc.Radius, 6);
// ARC entita je vždy CCW start→end; pro CW rozsah se meze prohodí, ale koncové BODY musí sedět.
var pStart = (arc.Center.X + r * Math.Cos(arc.StartAngle), arc.Center.Y + r * Math.Sin(arc.StartAngle));
var pEnd = (arc.Center.X + r * Math.Cos(arc.EndAngle), arc.Center.Y + r * Math.Sin(arc.EndAngle));
// množina koncových bodů oblouku se musí shodovat (v jednom z pořadí).
bool ok = (Blizko(pStart, s) && Blizko(pEnd, e)) || (Blizko(pStart, e) && Blizko(pEnd, s));
Assert.True(ok, $"koncové body oblouku nesedí: start={pStart} end={pEnd} vs s={s} e={e}");
static bool Blizko((double X, double Y) a, (double X, double Y) b)
=> Math.Abs(a.X - b.X) < 1e-6 && Math.Abs(a.Y - b.Y) < 1e-6;
}
}
+36
View File
@@ -0,0 +1,36 @@
using System.IO;
using Rozpisky.Dxf;
using SkiaSharp;
using Xunit;
namespace Rozpisky.Tests;
/// <summary>
/// Vyrenderuje všech 9 log do PNG vizuální porovnání před/po změnách parseru
/// (uživatel nemá screenshoty aplikace, výstupní soubory ano). Cílová složka
/// z env proměnné <c>ROZPISKY_PNG_OUT</c>, jinak %TEMP%\rozpisky-loga-png.
/// </summary>
public class PngHarness
{
[Fact]
public void VyrenderujVsechnaLogaDoPng()
{
string outDir = Environment.GetEnvironmentVariable("ROZPISKY_PNG_OUT")
?? Path.Combine(Path.GetTempPath(), "rozpisky-loga-png");
Directory.CreateDirectory(outDir);
const int W = 900, H = 600;
foreach (var name in TestData.VsechnaLoga)
{
var tpl = new DxfTemplate(TestData.Logo(name));
using var bmp = new SKBitmap(W, H);
using var canvas = new SKCanvas(bmp);
Exporters.RenderView(canvas, W, H, g => tpl.Replay(g), tpl.FrameBounds ?? tpl.Bounds,
zoom: 1, panXpx: 0, panYpx: 0);
using var img = SKImage.FromBitmap(bmp);
using var data = img.Encode(SKEncodedImageFormat.Png, 100);
using var fs = File.Create(Path.Combine(outDir, Path.ChangeExtension(name, ".png")));
data.SaveTo(fs);
}
}
}
+62
View File
@@ -0,0 +1,62 @@
using Rozpisky.Rendering;
namespace Rozpisky.Tests;
/// <summary>
/// Testovací <see cref="IProfileRenderer"/>: nic nekreslí, jen zaznamenává volání primitiv,
/// aby testy mohly assertovat, co přesně parser (<c>DxfTemplate.Replay</c>) vyprodukoval.
/// Obrysy (<see cref="Contour"/>) se pro pohodlné assertování bboxů zaznamenávají jak analyticky
/// (<see cref="FillContours"/>, <see cref="StrokeContours"/>), tak jemně zploštěné na body
/// (<see cref="Fills"/>, <see cref="Polylines"/>) zpětně kompatibilní s dřívějšími testy.
/// </summary>
public sealed class RecordingRenderer : IProfileRenderer
{
public sealed record TextCall(double X, double Y, string Text, double HeightMm, HAlign H, VAlign V,
double RotationDeg, TextStyle Style, double WidthFactor, double MaxWidthMm);
/// <summary>Každé volání Fill = smyčky jednoho HATCH, zploštěné na body.</summary>
public List<IReadOnlyList<IReadOnlyList<(double X, double Y)>>> Fills { get; } = new();
/// <summary>Analytické obrysy jednotlivých volání Fill (se zachovanými oblouky).</summary>
public List<IReadOnlyList<Contour>> FillContours { get; } = new();
/// <summary>Analytické obrysy z volání Stroke (bulge polylinie, samostatné oblouky).</summary>
public List<Contour> StrokeContours { get; } = new();
public List<TextCall> Texts { get; } = new();
/// <summary>Zploštěné polylinie i obrysy ze Stroke (kvůli zpětné kompatibilitě testů).</summary>
public List<IReadOnlyList<(double X, double Y)>> Polylines { get; } = new();
public int LineCount { get; private set; }
public int CircleCount { get; private set; }
public void Layer(string name, int rgb, double lineWeightMm = 0.25) { }
public void Line(double x1, double y1, double x2, double y2) => LineCount++;
public void Polyline(IReadOnlyList<(double X, double Y)> pts, bool closed = false) => Polylines.Add(pts);
public void Circle(double cx, double cy, double r) => CircleCount++;
public void Stroke(Contour c)
{
StrokeContours.Add(c);
Polylines.Add(Zplosti(c));
}
public void Text(double x, double y, string text, double heightMm,
HAlign h = HAlign.Left, VAlign v = VAlign.Baseline, double rotationDeg = 0,
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<Contour> loops)
{
FillContours.Add(loops);
Fills.Add(loops.Select(Zplosti).ToList());
}
/// <summary>Zploští obrys na body (jemně, sagitta ≤ 0,02 mm) pro bbox asserty v testech.</summary>
private static IReadOnlyList<(double X, double Y)> Zplosti(Contour c)
{
var pts = new List<(double X, double Y)>();
foreach (var e in c.Edges) e.AppendPoints(pts, maxSagitta: 0.02);
if (c.Edges.Count > 0) pts.Add((c.Edges[^1].EX, c.Edges[^1].EY));
return pts;
}
}
+21
View File
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Rozpisky\Rozpisky.csproj" />
</ItemGroup>
</Project>
+53
View File
@@ -0,0 +1,53 @@
using Rozpisky.Rendering;
using SkiaSharp;
using Xunit;
namespace Rozpisky.Tests;
/// <summary>
/// Skutečný render Skií (ne jen zplošťení obrysu): plná kružnice tvořená jedinou obloukovou
/// hranou (sweep 360°) se musí vykreslit, ne „propadnout" degenerovaným <c>ArcTo</c>.
/// </summary>
public class SkiaRenderTesty
{
/// <summary>Vykreslí kresbu na 200×200 px bitmapu (mm 1:1, střed plátna = mm (0,0)) a vrátí
/// počet nebílých pixelů.</summary>
private static int PocetVyplnenych(Action<IProfileRenderer> draw)
{
const int S = 200;
using var bmp = new SKBitmap(S, S);
using var canvas = new SKCanvas(bmp);
canvas.Clear(SKColors.White);
SKPoint Map(double x, double y) => new((float)(S / 2 + x * 10), (float)(S / 2 - y * 10)); // 10 px/mm, Y nahoru
using (var r = new SkiaProfileRenderer(canvas, Map, pxPerMm: 10))
draw(r);
int n = 0;
for (int y = 0; y < S; y++)
for (int x = 0; x < S; x++)
if (bmp.GetPixel(x, y) != SKColors.White) n++;
return n;
}
[Fact]
public void PlnaKruznice_JakoJednaObloukovaHrana_SeVyplni()
{
// Kruh r=5 mm ze středu (0,0), jediná Arc hrana se sweepem 360°.
var edge = Edge.Arc(5, 0, 5, 0, 0, 0, r: 5, a0: 0, sweep: 2 * Math.PI);
var contour = new Contour(new[] { edge }, Closed: true);
int filled = PocetVyplnenych(g => { g.Layer("0", 0); g.Fill(new[] { contour }); });
// Plocha kruhu r=5 mm × (10 px/mm)² = π·2500 ≈ 7854 px; tolerantně > 5000.
Assert.True(filled > 5000, $"plná kružnice se nevykreslila (vyplněno {filled} px)");
}
[Fact]
public void ObloukovyOblouk_SeVykresli_Stroke()
{
// Kontrolní: běžný oblouk (< 360°) přes Stroke musí něco nakreslit.
var edge = Edge.Arc(5, 0, 0, 5, 0, 0, r: 5, a0: 0, sweep: Math.PI / 2);
int filled = PocetVyplnenych(g => { g.Layer("0", 0); g.Stroke(new Contour(new[] { edge }, Closed: false)); });
Assert.True(filled > 50, $"oblouk se nevykreslil (vyplněno {filled} px)");
}
}
+31
View File
@@ -0,0 +1,31 @@
using System.IO;
namespace Rozpisky.Tests;
/// <summary>Cesty k testovacím datům (loga DXF dodávaná s programem).</summary>
public static class TestData
{
/// <summary>Kořen repozitáře nalezen od bin složky testů směrem nahoru podle Rozpisky.sln.</summary>
public static string RepoRoot { get; } = NajdiKoren();
/// <summary>Plná cesta k logu ve zdrojové složce <c>Rozpisky\loga</c>.</summary>
public static string Logo(string fileName) => Path.Combine(RepoRoot, "Rozpisky", "loga", fileName);
/// <summary>Všech 9 log dodávaných s programem.</summary>
public static readonly string[] VsechnaLoga =
{
"eu_op_doprava.dxf", "md_sfdi.dxf", "sprava_zeleznic.dxf", "exprojekt.dxf",
"signalprojekt.dxf", "tesia.dxf", "mco.dxf", "exprojekt+mco.dxf", "sudopBrno.dxf",
};
private static string NajdiKoren()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir is not null)
{
if (File.Exists(Path.Combine(dir.FullName, "Rozpisky.sln"))) return dir.FullName;
dir = dir.Parent!;
}
throw new InvalidOperationException("Rozpisky.sln nenalezen nad " + AppContext.BaseDirectory);
}
}