Oprava: exportovaná šrafa šla ven bez definice vzoru a ve špatném měřítku

Nahlásil uživatel: šrafa v exportovaném DXF nedrží měřítko, takže na první
pohled ani nevypadá jako šrafa, a v každém výkresu se chová jinak.

Dvě příčiny:

1. Do výstupu šlo jen *jméno* vzoru (ANSI31), bez definičních čar — ověřeno
   na exportovaném souboru: definicnichCar=0. DXF přitom nese definici vzoru
   uvnitř entity HATCH; bez ní si ji cílový CAD dosadí ze svých .pat, ve svých
   jednotkách a měřítku. Odtud „v každém výkresu jinak". Vypln proto nese
   VzorCara (úhel, vztažný bod, rozteč, čárkování) a export ji zapisuje.

2. Vzor se neškáloval spolu s geometrií. Logo se do rámečku rozpisky vejde jen
   zmenšené (typicky ~0,1×), ale rozteč vzoru zůstávala původní — v CADu z toho
   byla buď plná plocha, nebo pár čar přes celé logo. TransformRenderer teď
   mapuje i definici: vztažný bod celou transformací, rozteč jako vektor (bez
   posunu), délky čárek a měřítko × měřítko transformace.

Měřítko a úhel se do HATCH zapisují neutrální (1 a 0) a efektivní hodnoty nese
definice. ACadSharp totiž při zápisu násobí rozteč hodnotou PatternScale, ale
při čtení zpátky nedělí — s nenulovým měřítkem by se vzor s každým kolečkem
desetkrát roztáhl (změřeno).

Po opravě je rozteč v exportu shodná se zdrojem 1:1 a při zmenšení loga 0,1×
klesne desetkrát, takže počet čar přes logo zůstává stejný. Náhledy všech
9 log jsou pixel po pixelu beze změny. 119 testů zelených.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-07 15:08:54 +02:00
co-authored by Claude Opus 5
parent 10548b5698
commit 1e5a552c13
11 changed files with 225 additions and 36 deletions
+1 -1
View File
@@ -51,6 +51,6 @@ Needituj produkční data: `Rozpisky/Podklady/Rozpiska.dxf`, `vzorky/schema.dxf`
3. Ověř:
`dotnet build "Rozpisky.sln" -nologo -v q -clp:ErrorsOnly` a
`dotnet test "Rozpisky.Tests\Rozpisky.Tests.csproj" -nologo -v q`
(baseline je 114 zelených testů — nesmí ubýt).
(baseline je 119 zelených testů — nesmí ubýt).
4. V odpovědi vrať: co jsi změnil (soubor:řádek), proč, a výsledek buildu a testů. Když něco
neprošlo, řekni to rovnou i s chybovou hláškou — nezakrývej to.
+1 -1
View File
@@ -18,7 +18,7 @@ dotnet test "C:\Users\marek\_Osobní\C sharp\EXE\Rozpisky\Rozpisky.Tests\Rozpis
Testy spusť i tehdy, když build projde s varováními. Když build spadne, testy nespouštěj
a rovnou hlas chyby buildu.
Baseline, se kterou porovnáváš: **build bez chyb a bez varování, 114 testů zelených, 0 přeskočených.**
Baseline, se kterou porovnáváš: **build bez chyb a bez varování, 119 testů zelených, 0 přeskočených.**
Volitelně (jen když o to volající požádá) ověř, že aplikace naběhne:
spusť `Rozpisky\bin\Debug\net10.0-windows\Rozpisky.exe`, počkej ~8 s, zkontroluj, že proces
+1 -1
View File
@@ -36,7 +36,7 @@ nebo `Rozpisky/Xlsx/` jen popiš v odpovědi a nech je na volajícím.
začneš měnit UI.
2. `MainViewModel.cs` je velký; hledej v něm grepem, nečti ho celý zbytečně.
3. Ověř: `dotnet build "Rozpisky.sln" -nologo -v q -clp:ErrorsOnly` a
`dotnet test "Rozpisky.Tests\Rozpisky.Tests.csproj" -nologo -v q` (baseline 114 zelených).
`dotnet test "Rozpisky.Tests\Rozpisky.Tests.csproj" -nologo -v q` (baseline 119 zelených).
Build musí být **bez varování** — chyby v XAML se často projeví jen jako varování.
4. WPF chyby v bindingu build nezachytí. Pokud změna může binding rozbít, řekni to explicitně
a doporuč ruční spuštění aplikace.
+1 -1
View File
@@ -11,7 +11,7 @@ dotnet test "Rozpisky.Tests\Rozpisky.Tests.csproj" -nologo -v q
dotnet run --project "Rozpisky\Rozpisky.csproj"
```
Baseline: build bez chyb a varování, **114 testů zelených**. Před hlášením hotovo obojí spustit
Baseline: build bez chyb a varování, **119 testů zelených**. Před hlášením hotovo obojí spustit
(nebo použít `/overit`).
## Architektura — jediné pravidlo, které se nesmí porušit
+111
View File
@@ -0,0 +1,111 @@
using System.IO;
using ACadSharp.Entities;
using ACadSharp.IO;
using Rozpisky.Dxf;
using Rozpisky.Rendering;
using Xunit;
namespace Rozpisky.Tests;
/// <summary>
/// Šrafa v exportovaném DXF. Vzor musí jít ven i s <b>definičními čarami</b> DXF je nese uvnitř
/// entity <c>HATCH</c>, a bez nich si ho cílový CAD dosadí ze svých <c>.pat</c>, ve svých
/// jednotkách a měřítku. Pak vypadá v každém výkresu jinak a většinou tak hustě, že to ani
/// nevypadá jako šrafa. A protože se logo do rámečku zmenšuje, musí se s ním zmenšit i vzor.
/// </summary>
public class SrafaExportTesty : IDisposable
{
private readonly string _soubor = Path.Combine(Path.GetTempPath(), "rozpisky-srafa-" + Guid.NewGuid().ToString("N") + ".dxf");
public void Dispose()
{
try { File.Delete(_soubor); } catch (IOException) { }
GC.SuppressFinalize(this);
}
/// <summary>Přehraje logo do DXF (volitelně přes zmenšení do rámečku) a načte výsledek zpět.</summary>
private List<Hatch> Kolecko(double meritko)
{
var dxf = new DxfProfileRenderer();
IProfileRenderer cil = meritko == 1 ? dxf : new TransformRenderer(dxf, meritko, 0, 0);
new DxfTemplate(TestData.Logo("md_sfdi.dxf")).Replay(cil);
DxfWriter.Write(_soubor, dxf.Document, binary: true);
return DxfReader.Read(_soubor).Entities.OfType<Hatch>().ToList();
}
/// <summary>Rozteč rovnoběžek vzoru = kolmá složka posunu mezi sousedními čarami.</summary>
private static double Roztec(HatchPattern.Line cara)
{
double nx = -Math.Sin(cara.Angle), ny = Math.Cos(cara.Angle);
return Math.Abs(cara.Offset.X * nx + cara.Offset.Y * ny);
}
private static double RoztecZdroje()
{
var zdroj = DxfReader.Read(TestData.Logo("md_sfdi.dxf"))
.Entities.OfType<Hatch>().First(h => !h.IsSolid);
return Roztec(zdroj.Pattern!.Lines[0]);
}
[Fact]
public void ExportovanaSrafaNeseDefiniciVzoru()
{
var srafy = Kolecko(meritko: 1).Where(h => !h.IsSolid).ToList();
Assert.NotEmpty(srafy);
foreach (var h in srafy)
{
Assert.Equal("ANSI31", h.Pattern?.Name);
Assert.NotNull(h.Pattern);
Assert.NotEmpty(h.Pattern!.Lines); // tohle chybělo: vzor šel ven jen jménem
}
}
[Fact]
public void RoztecVzoruOdpovidaZdroji()
{
var srafa = Kolecko(meritko: 1).First(h => !h.IsSolid);
Assert.Equal(RoztecZdroje(), Roztec(srafa.Pattern!.Lines[0]), 9);
}
[Fact]
public void ZmensenimLogaSeZmensiIVzor()
{
// Logo se do rámečku rozpisky vejde jen zmenšené. Kdyby si vzor nechal původní rozteč,
// vyšla by v CADu buď plná plocha, nebo pár čar přes celé logo.
const double meritko = 0.1;
var srafa = Kolecko(meritko).First(h => !h.IsSolid);
Assert.Equal(RoztecZdroje() * meritko, Roztec(srafa.Pattern!.Lines[0]), 9);
}
[Fact]
public void MeritkoAUhelZustavajiNeutralni()
{
// ACadSharp při zápisu násobí rozteč vzoru hodnotou PatternScale, ale při čtení zpátky
// nedělí s nenulovým měřítkem by se vzor s každým kolečkem roztáhl. Efektivní hodnoty
// proto nese samotná definice a měřítko s úhlem zůstávají neutrální.
var srafa = Kolecko(meritko: 1).First(h => !h.IsSolid);
Assert.Equal(1, srafa.PatternScale, 9);
Assert.Equal(0, srafa.PatternAngle, 9);
}
[Fact]
public void PlnaVyplnZustavaSolidBezVzoru()
{
// md_sfdi má všechny šrafy vzorované, plné výplně jsou v ostatních logách.
var dxf = new DxfProfileRenderer();
new DxfTemplate(TestData.Logo("exprojekt.dxf")).Replay(dxf);
DxfWriter.Write(_soubor, dxf.Document, binary: true);
var solidni = DxfReader.Read(_soubor).Entities.OfType<Hatch>().ToList();
Assert.NotEmpty(solidni);
foreach (var h in solidni)
{
Assert.True(h.IsSolid);
Assert.Equal("SOLID", h.Pattern?.Name);
Assert.Empty(h.Pattern!.Lines);
}
}
}
+16 -1
View File
@@ -384,8 +384,23 @@ public sealed partial class DxfTemplate
|| vzor.Equals("SOLID", StringComparison.OrdinalIgnoreCase))
return default;
var definice = DefiniceVzoru(hatch.Pattern);
return Vypln.Vzorovana(vzor, hatch.PatternScale, hatch.PatternAngle * RadToDeg,
SrafaVzor.Cary(hatch.Pattern, smycky));
definice, SrafaVzor.Cary(definice, smycky));
}
/// <summary>Definiční čáry vzoru šrafy z entity. DXF je nese uvnitř <c>HATCH</c> (group 78 a dál),
/// tedy už v efektivních hodnotách měřítko a úhel z vlastností šrafy jsou v nich započítané.</summary>
private static List<VzorCara> DefiniceVzoru(HatchPattern? vzor)
{
var vysledek = new List<VzorCara>();
foreach (var cara in vzor?.Lines ?? new List<HatchPattern.Line>())
vysledek.Add(new VzorCara(
cara.Angle * RadToDeg,
(cara.BasePoint.X, cara.BasePoint.Y),
(cara.Offset.X, cara.Offset.Y),
cara.DashLengths?.ToList() ?? new List<double>()));
return vysledek;
}
/// <summary>Popis hladiny pro kreslítko celý, ať se dá ve výstupu postavit znovu
+9 -9
View File
@@ -1,4 +1,3 @@
using ACadSharp.Entities;
using Rozpisky.Rendering;
namespace Rozpisky.Dxf;
@@ -19,10 +18,10 @@ internal static class SrafaVzor
/// <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)
public static IReadOnlyList<Usecka> Cary(IReadOnlyList<VzorCara>? definice, IReadOnlyList<Contour> smycky)
{
var vysledek = new List<Usecka>();
if (vzor?.Lines is not { Count: > 0 }) return vysledek;
if (definice is not { Count: > 0 }) return vysledek;
var polygony = Zplosti(smycky);
if (polygony.Count == 0) return vysledek;
@@ -30,7 +29,7 @@ internal static class SrafaVzor
var (minX, minY, maxX, maxY) = Rozsah(polygony);
if (maxX - minX <= 0 || maxY - minY <= 0) return vysledek;
foreach (var cara in vzor.Lines)
foreach (var cara in definice)
{
PridejCaryVzoru(cara, polygony, minX, minY, maxX, maxY, vysledek);
if (vysledek.Count >= MaxCar) break;
@@ -42,10 +41,11 @@ internal static class SrafaVzor
/// 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,
VzorCara 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 uhel = cara.UhelDeg * Math.PI / 180;
double dx = Math.Cos(uhel), dy = Math.Sin(uhel); // směr čáry
double nx = -dy, ny = dx; // kolmice
double ox = cara.Offset.X, oy = cara.Offset.Y;
@@ -53,7 +53,7 @@ internal static class SrafaVzor
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;
double zaklad = cara.Zaklad.X * nx + cara.Zaklad.Y * ny;
var projekce = new[]
{
minX * nx + minY * ny, maxX * nx + minY * ny,
@@ -66,11 +66,11 @@ internal static class SrafaVzor
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);
var carky = Carky(cara.Carky);
for (int k = od; k <= doK; k++)
{
double px = cara.BasePoint.X + k * ox, py = cara.BasePoint.Y + k * oy;
double px = cara.Zaklad.X + k * ox, py = cara.Zaklad.Y + k * oy;
foreach (var (t1, t2) in Uvnitr(polygony, px, py, dx, dy))
foreach (var (u1, u2) in Orez(carky, t1, t2))
{
+33 -5
View File
@@ -165,14 +165,20 @@ public sealed class DxfProfileRenderer : IProfileRenderer
if (loops.Count == 0) return;
try
{
// 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.
// Vzor šrafy se zapisuje zpátky i s definičními čarami DXF je nese uvnitř entity
// HATCH, a bez nich si cílový CAD vzor dosadí ze svých .pat, ve svých jednotkách
// a měřítku. Rozvinuté čáry (Vypln.Cary) jsou naopak jen pro rastrové renderery.
//
// Měřítko a úhel zůstávají neutrální (1 a 0), protože definiční čáry jsou už
// v efektivních hodnotách měřítko i natočení jsou v nich započítané. ACadSharp
// při zápisu násobí rozteč vzoru hodnotou PatternScale, ale při čtení zpátky nedělí;
// s nenulovým měřítkem by se tak vzor s každým kolečkem roztáhl.
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,
Pattern = VzorProHatch(vypln),
PatternScale = 1,
PatternAngle = 0,
};
foreach (var loop in loops)
{
@@ -201,6 +207,28 @@ public sealed class DxfProfileRenderer : IProfileRenderer
}
}
/// <summary>Vzor pro entitu HATCH: jméno a definiční čáry. Bez definičních čar má výstup jen
/// jméno vzoru a cílový CAD si podle něj dosadí vlastní definici v jiných jednotkách
/// a jiném měřítku, takže šrafa vypadá v každém výkresu jinak.</summary>
private static HatchPattern VzorProHatch(Vypln vypln)
{
var vzor = new HatchPattern(vypln.JeSolid ? "SOLID" : vypln.Vzor!);
if (vypln.JeSolid) return vzor;
foreach (var cara in vypln.Definice ?? Array.Empty<VzorCara>())
{
var radek = new HatchPattern.Line
{
Angle = cara.UhelDeg * Math.PI / 180.0,
BasePoint = new XY(cara.Zaklad.X, cara.Zaklad.Y),
Offset = new XY(cara.Offset.X, cara.Offset.Y),
};
foreach (var d in cara.Carky) radek.DashLengths.Add(d);
vzor.Lines.Add(radek);
}
return vzor;
}
/// <summary>Směrový vektor osy X textu pro dané natočení tím se natáčí MTEXT.</summary>
private static XYZ SmerOsyX(double rotationDeg)
{
+21 -7
View File
@@ -56,23 +56,37 @@ public readonly record struct Edge(
/// <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>
/// Jedna definiční čára vzoru šrafy = celá rodina rovnoběžek: směr <paramref name="UhelDeg"/>,
/// jedna čára prochází <paramref name="Zaklad"/>, sousední jsou posunuté o vektor
/// <paramref name="Offset"/> (kolmá složka = rozteč, podélná posouvá čárkování).
/// <paramref name="Carky"/> = délky čárek a mezer (záporná = mezera); prázdné = plná čára.
/// <para>Tohle je <b>skutečná definice vzoru</b>, ne jen jeho jméno. DXF ji nese uvnitř entity
/// <c>HATCH</c>, takže je nutné ji zapsat i do výstupu jinak si ji cílový CAD dosadí ze svých
/// <c>.pat</c> souborů, ve svých jednotkách a měřítku, a šrafa vyjde pokaždé jinak.</para>
/// </summary>
public readonly record struct VzorCara(
double UhelDeg, (double X, double Y) Zaklad, (double X, double Y) Offset, IReadOnlyList<double> Carky);
/// <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>
/// ignoruje a zapíše zpátky <c>HATCH</c> se jménem vzoru, měřítkem, úhlem a <b>definičními čarami</b>
/// (<paramref name="Definice"/>), aby zůstal editovatelný a vypadal stejně jako ve zdroji. 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)
string? Vzor = null, double Meritko = 1, double UhelDeg = 0,
IReadOnlyList<Usecka>? Cary = null, IReadOnlyList<VzorCara>? Definice = 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>Šrafa se vzorem: definice pro DXF a z ní rozvinuté čáry pro rastr.</summary>
public static Vypln Vzorovana(string vzor, double meritko, double uhelDeg,
IReadOnlyList<VzorCara> definice, IReadOnlyList<Usecka> cary)
=> new(vzor, meritko, uhelDeg, cary, definice);
}
/// <summary>Obrys = zřetězené hrany (konec jedné = počátek další). <paramref name="Closed"/> = true →
+26 -8
View File
@@ -132,14 +132,32 @@ public sealed class TransformRenderer : IProfileRenderer
=> _inner.Fill(
loops.Select(c => new Contour(
_zachovavaOblouky ? c.Edges.Select(T).ToList() : Usecky(c), c.Closed)).ToList(),
vypln.Cary is null
? vypln
: vypln with
{
Cary = vypln.Cary
.Select(u => new Usecka(X(u.X1, u.Y1), Y(u.X1, u.Y1), X(u.X2, u.Y2), Y(u.X2, u.Y2)))
.ToList(),
});
Vypln(vypln));
/// <summary>Přemapuje výplň spolu s geometrií. Šrafa se musí zmenšit se svou plochou
/// jinak logo zmenšené do rámečku dostane vzor v původní rozteči a v CADu z něj vyjde
/// buď plná plocha, nebo pár čar přes celé logo.</summary>
private Vypln Vypln(Vypln v)
{
if (v.JeSolid) return v;
return v with
{
Meritko = v.Meritko * _meritko,
UhelDeg = v.UhelDeg + _uhelDeg,
Cary = v.Cary?.Select(u => new Usecka(X(u.X1, u.Y1), Y(u.X1, u.Y1), X(u.X2, u.Y2), Y(u.X2, u.Y2))).ToList(),
Definice = v.Definice?.Select(Vzor).ToList(),
};
}
/// <summary>Definiční čára vzoru: vztažný bod je bod (celá transformace), rozteč je vektor
/// (jen lineární část, bez posunu), délky čárek se škálují, směr se otočí.</summary>
private VzorCara Vzor(VzorCara c) => c with
{
UhelDeg = c.UhelDeg + _uhelDeg,
Zaklad = (X(c.Zaklad.X, c.Zaklad.Y), Y(c.Zaklad.X, c.Zaklad.Y)),
Offset = (_a * c.Offset.X + _c * c.Offset.Y, _b * c.Offset.X + _d * c.Offset.Y),
Carky = c.Carky.Select(d => d * _meritko).ToList(),
};
/// <summary>Obrys rozložený na úsečky (pro transformace, které oblouk nezachovají).</summary>
private List<Edge> Usecky(Contour c)
+5 -2
View File
@@ -189,8 +189,11 @@ Implementace:
- **Zarovnání textu `Aligned` / `Fit`** se aproximuje na `Left` text se neroztáhne na cílovou délku.
- **Typ čáry** se přenáší jako název, ale rastrové renderery kreslí vždy plnou čáru; čárkované styly
se projeví až v DXF (v dodávaných souborech je stejně všude `Continuous`).
- **Šrafy** se do DXF zapisují se jménem vzoru, měřítkem a úhlem, ale bez definice čar vzoru CAD
si ji dohledá ve svých `.pat`. Vzor, který cílový CAD nezná, se v něm nevykreslí.
- **Šrafy** se do DXF zapisují i s definičními čarami vzoru (DXF je nese uvnitř entity `HATCH`),
takže vypadají stejně jako ve zdroji nezávisle na tom, jaké `.pat` a jednotky má cílový CAD.
Měřítko a úhel šrafy zůstávají neutrální (1 a 0) efektivní hodnoty nese samotná definice.
Důvod: ACadSharp při zápisu násobí rozteč vzoru hodnotou `PatternScale`, ale při čtení zpátky
nedělí, takže s nenulovým měřítkem by se vzor s každým kolečkem roztáhl.
---