diff --git a/.claude/agents/render-dxf.md b/.claude/agents/render-dxf.md index 63b20a1..bd5cb23 100644 --- a/.claude/agents/render-dxf.md +++ b/.claude/agents/render-dxf.md @@ -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. diff --git a/.claude/agents/verifikator.md b/.claude/agents/verifikator.md index 5536cd7..d83f403 100644 --- a/.claude/agents/verifikator.md +++ b/.claude/agents/verifikator.md @@ -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 diff --git a/.claude/agents/wpf-ui.md b/.claude/agents/wpf-ui.md index a207945..942d106 100644 --- a/.claude/agents/wpf-ui.md +++ b/.claude/agents/wpf-ui.md @@ -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. diff --git a/CLAUDE.md b/CLAUDE.md index f696ac9..026400a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/Rozpisky.Tests/SrafaExportTesty.cs b/Rozpisky.Tests/SrafaExportTesty.cs new file mode 100644 index 0000000..5f04b97 --- /dev/null +++ b/Rozpisky.Tests/SrafaExportTesty.cs @@ -0,0 +1,111 @@ +using System.IO; +using ACadSharp.Entities; +using ACadSharp.IO; +using Rozpisky.Dxf; +using Rozpisky.Rendering; +using Xunit; + +namespace Rozpisky.Tests; + +/// +/// Šrafa v exportovaném DXF. Vzor musí jít ven i s definičními čarami – DXF je nese uvnitř +/// entity HATCH, a bez nich si ho cílový CAD dosadí ze svých .pat, 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. +/// +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); + } + + /// Přehraje logo do DXF (volitelně přes zmenšení do rámečku) a načte výsledek zpět. + private List 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().ToList(); + } + + /// Rozteč rovnoběžek vzoru = kolmá složka posunu mezi sousedními čarami. + 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().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().ToList(); + + Assert.NotEmpty(solidni); + foreach (var h in solidni) + { + Assert.True(h.IsSolid); + Assert.Equal("SOLID", h.Pattern?.Name); + Assert.Empty(h.Pattern!.Lines); + } + } +} diff --git a/Rozpisky/Dxf/DxfTemplate.cs b/Rozpisky/Dxf/DxfTemplate.cs index 43905fa..57d9dc6 100644 --- a/Rozpisky/Dxf/DxfTemplate.cs +++ b/Rozpisky/Dxf/DxfTemplate.cs @@ -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)); + } + + /// Definiční čáry vzoru šrafy z entity. DXF je nese uvnitř HATCH (group 78 a dál), + /// tedy už v efektivních hodnotách – měřítko a úhel z vlastností šrafy jsou v nich započítané. + private static List DefiniceVzoru(HatchPattern? vzor) + { + var vysledek = new List(); + foreach (var cara in vzor?.Lines ?? new List()) + vysledek.Add(new VzorCara( + cara.Angle * RadToDeg, + (cara.BasePoint.X, cara.BasePoint.Y), + (cara.Offset.X, cara.Offset.Y), + cara.DashLengths?.ToList() ?? new List())); + return vysledek; } /// Popis hladiny pro kreslítko – celý, ať se dá ve výstupu postavit znovu diff --git a/Rozpisky/Dxf/SrafaVzor.cs b/Rozpisky/Dxf/SrafaVzor.cs index 22f53d6..3baa4b6 100644 --- a/Rozpisky/Dxf/SrafaVzor.cs +++ b/Rozpisky/Dxf/SrafaVzor.cs @@ -1,4 +1,3 @@ -using ACadSharp.Entities; using Rozpisky.Rendering; namespace Rozpisky.Dxf; @@ -19,10 +18,10 @@ internal static class SrafaVzor /// Čá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) + public static IReadOnlyList Cary(IReadOnlyList? definice, IReadOnlyList smycky) { var vysledek = new List(); - 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 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, + VzorCara 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 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)) { diff --git a/Rozpisky/Rendering/DxfProfileRenderer.cs b/Rozpisky/Rendering/DxfProfileRenderer.cs index 83a8aba..e00b5ff 100644 --- a/Rozpisky/Rendering/DxfProfileRenderer.cs +++ b/Rozpisky/Rendering/DxfProfileRenderer.cs @@ -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 } } + /// 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. + 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()) + { + 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; + } + /// Směrový vektor osy X textu pro dané natočení – tím se natáčí MTEXT. private static XYZ SmerOsyX(double rotationDeg) { diff --git a/Rozpisky/Rendering/IProfileRenderer.cs b/Rozpisky/Rendering/IProfileRenderer.cs index a089e6b..5ef9703 100644 --- a/Rozpisky/Rendering/IProfileRenderer.cs +++ b/Rozpisky/Rendering/IProfileRenderer.cs @@ -56,23 +56,37 @@ public readonly record struct Edge( /// Úsečka ve výkresových mm – čára vzoru šrafy. public readonly record struct Usecka(double X1, double Y1, double X2, double Y2); +/// +/// Jedna definiční čára vzoru šrafy = celá rodina rovnoběžek: směr , +/// jedna čára prochází , sousední jsou posunuté o vektor +/// (kolmá složka = rozteč, podélná posouvá čárkování). +/// = délky čárek a mezer (záporná = mezera); prázdné = plná čára. +/// Tohle je skutečná definice vzoru, ne jen jeho jméno. DXF ji nese uvnitř entity +/// HATCH, takže je nutné ji zapsat i do výstupu – jinak si ji cílový CAD dosadí ze svých +/// .pat souborů, ve svých jednotkách a měřítku, a šrafa vyjde pokaždé jinak. +/// +public readonly record struct VzorCara( + double UhelDeg, (double X, double Y) Zaklad, (double X, double Y) Offset, IReadOnlyList Carky); + /// /// 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. +/// ignoruje a zapíše zpátky HATCH se jménem vzoru, měřítkem, úhlem a definičními čarami +/// (), aby zůstal editovatelný a vypadal stejně jako ve zdroji. 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) + string? Vzor = null, double Meritko = 1, double UhelDeg = 0, + IReadOnlyList? Cary = null, IReadOnlyList? Definice = 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); + /// Šrafa se vzorem: definice pro DXF a z ní rozvinuté čáry pro rastr. + public static Vypln Vzorovana(string vzor, double meritko, double uhelDeg, + IReadOnlyList definice, IReadOnlyList cary) + => new(vzor, meritko, uhelDeg, cary, definice); } /// Obrys = zřetězené hrany (konec jedné = počátek další). = true → diff --git a/Rozpisky/Rendering/TransformRenderer.cs b/Rozpisky/Rendering/TransformRenderer.cs index d39269a..90af946 100644 --- a/Rozpisky/Rendering/TransformRenderer.cs +++ b/Rozpisky/Rendering/TransformRenderer.cs @@ -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)); + + /// 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. + 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(), + }; + } + + /// 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čí. + 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(), + }; /// Obrys rozložený na úsečky (pro transformace, které oblouk nezachovají). private List Usecky(Contour c) diff --git a/docs/ROZPISKY.md b/docs/ROZPISKY.md index 68dd564..adeb461 100644 --- a/docs/ROZPISKY.md +++ b/docs/ROZPISKY.md @@ -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. ---