feat(io): add Gravograph .CHR font reader with text-to-geometry
Add ChrFont, a reader for Gravograph .CHR engraving fonts, plus UI to convert placed text into engraved geometry in the CAD converter. The .CHR files are obfuscated with a single-byte XOR. Different GravoStyle releases use different keys (0x2F in older versions, 0xCF in the 7000 series, and others across the font library), so the key is auto-detected from byte 1 of the file: the font name is ASCII stored as UTF-16LE, so the high byte of its first character is 0x00 in plaintext and the raw byte equals the key. This reads every font in a GravoStyle install regardless of version, not just one hardcoded key. UI: right-clicking a text item in EntityView raises TextConvertRequested; CadConverterForm renders it via ChrFont with H/V alignment and adds the result on an ENGRAVE layer. Tests use Xunit.SkippableFact and a gitignored test-config.json so the suite points at a local .CHR file without committing proprietary assets. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -213,3 +213,6 @@ docs/superpowers/
|
|||||||
|
|
||||||
# Launch settings
|
# Launch settings
|
||||||
**/Properties/launchSettings.json
|
**/Properties/launchSettings.json
|
||||||
|
|
||||||
|
# Local test config (contains user-specific paths to proprietary test assets)
|
||||||
|
OpenNest.Tests/test-config.json
|
||||||
|
|||||||
@@ -0,0 +1,369 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.IO
|
||||||
|
{
|
||||||
|
public class ChrFont
|
||||||
|
{
|
||||||
|
private readonly Dictionary<int, ChrGlyph> glyphs = new();
|
||||||
|
|
||||||
|
public string Name { get; internal set; }
|
||||||
|
public string Version { get; internal set; }
|
||||||
|
public double CapHeight { get; internal set; } = 5000;
|
||||||
|
|
||||||
|
internal void AddGlyph(int charCode, ChrGlyph glyph)
|
||||||
|
{
|
||||||
|
glyphs[charCode] = glyph;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool HasGlyph(int charCode) => glyphs.ContainsKey(charCode);
|
||||||
|
|
||||||
|
public ChrGlyph GetGlyph(int charCode) =>
|
||||||
|
glyphs.TryGetValue(charCode, out var g) ? g : null;
|
||||||
|
|
||||||
|
public double MeasureTextWidth(string text, double height)
|
||||||
|
{
|
||||||
|
var scale = height / CapHeight;
|
||||||
|
double width = 0;
|
||||||
|
foreach (var ch in text)
|
||||||
|
{
|
||||||
|
var glyph = GetGlyph(ch);
|
||||||
|
if (glyph == null)
|
||||||
|
{
|
||||||
|
var space = GetGlyph(' ');
|
||||||
|
width += (space?.AdvanceWidth ?? CapHeight * 0.6) * scale;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
width += glyph.AdvanceWidth * scale;
|
||||||
|
}
|
||||||
|
return width;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Entity> RenderText(string text, double height, Vector position, Layer layer = null)
|
||||||
|
{
|
||||||
|
var scale = height / CapHeight;
|
||||||
|
var entities = new List<Entity>();
|
||||||
|
var cursorX = position.X;
|
||||||
|
|
||||||
|
foreach (var ch in text)
|
||||||
|
{
|
||||||
|
var glyph = GetGlyph(ch);
|
||||||
|
if (glyph == null)
|
||||||
|
{
|
||||||
|
var space = GetGlyph(' ');
|
||||||
|
cursorX += (space?.AdvanceWidth ?? CapHeight * 0.6) * scale;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var glyphEntities = glyph.ToEntities(scale, cursorX, position.Y, layer);
|
||||||
|
entities.AddRange(glyphEntities);
|
||||||
|
cursorX += glyph.AdvanceWidth * scale;
|
||||||
|
}
|
||||||
|
|
||||||
|
return entities;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ChrFont Read(string path, byte? xorKey = null)
|
||||||
|
{
|
||||||
|
var raw = File.ReadAllBytes(path);
|
||||||
|
|
||||||
|
// The whole file is obfuscated with a single-byte XOR. Different
|
||||||
|
// GravoStyle versions use different keys (0x2F in older releases,
|
||||||
|
// 0xCF in 7000-series). The font name at offset 0 is ASCII stored
|
||||||
|
// as UTF-16LE, so the high byte of its first character is 0x00 in
|
||||||
|
// plaintext — which means raw[1] is exactly the XOR key. Detect it
|
||||||
|
// from the file unless the caller forces a specific key.
|
||||||
|
var key = xorKey ?? (raw.Length > 1 ? raw[1] : (byte)0x2F);
|
||||||
|
|
||||||
|
var data = new byte[raw.Length];
|
||||||
|
for (var i = 0; i < raw.Length; i++)
|
||||||
|
data[i] = (byte)(raw[i] ^ key);
|
||||||
|
|
||||||
|
return Parse(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ChrFont Parse(byte[] data)
|
||||||
|
{
|
||||||
|
var font = new ChrFont();
|
||||||
|
|
||||||
|
font.Name = Encoding.Unicode.GetString(data, 0, 26).TrimEnd('\0').Trim();
|
||||||
|
font.Version = Encoding.ASCII.GetString(data, 26, 12).TrimEnd('\0').Trim();
|
||||||
|
|
||||||
|
var charTable = new List<(int charCode, int offset)>();
|
||||||
|
var i = 0x40;
|
||||||
|
while (i + 5 < data.Length)
|
||||||
|
{
|
||||||
|
var charCode = data[i] | (data[i + 1] << 8);
|
||||||
|
var offset = data[i + 2] | (data[i + 3] << 8) | (data[i + 4] << 16) | (data[i + 5] << 24);
|
||||||
|
|
||||||
|
if (charCode < 0x20 || offset == 0 || offset >= data.Length)
|
||||||
|
break;
|
||||||
|
|
||||||
|
charTable.Add((charCode, offset));
|
||||||
|
i += 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var c = 0; c < charTable.Count; c++)
|
||||||
|
{
|
||||||
|
var (charCode, offset) = charTable[c];
|
||||||
|
|
||||||
|
var nextOffset = c + 1 < charTable.Count
|
||||||
|
? FindNextOffset(charTable, offset, data.Length)
|
||||||
|
: data.Length;
|
||||||
|
|
||||||
|
var glyph = ParseGlyph(data, offset, nextOffset);
|
||||||
|
if (glyph != null)
|
||||||
|
font.AddGlyph(charCode, glyph);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (font.glyphs.Count > 0)
|
||||||
|
{
|
||||||
|
foreach (var g in font.glyphs.Values)
|
||||||
|
{
|
||||||
|
if (g.CapHeight > 0)
|
||||||
|
{
|
||||||
|
font.CapHeight = g.CapHeight;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return font;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int FindNextOffset(List<(int charCode, int offset)> table, int currentOffset, int fileLength)
|
||||||
|
{
|
||||||
|
var best = fileLength;
|
||||||
|
foreach (var (_, off) in table)
|
||||||
|
{
|
||||||
|
if (off > currentOffset && off < best)
|
||||||
|
best = off;
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ChrGlyph ParseGlyph(byte[] data, int offset, int endOffset)
|
||||||
|
{
|
||||||
|
if (offset + 92 > data.Length)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var glyph = new ChrGlyph();
|
||||||
|
glyph.CapHeight = ReadBE16(data, offset + 15 * 2);
|
||||||
|
var bearing = System.Math.Abs(ReadBE16(data, offset + 18 * 2));
|
||||||
|
glyph.AdvanceWidth = ReadBE16(data, offset + 22 * 2) + bearing;
|
||||||
|
|
||||||
|
var strokeStart = offset + 92;
|
||||||
|
var pos = strokeStart;
|
||||||
|
var currentStroke = new List<ChrStrokePoint>();
|
||||||
|
|
||||||
|
while (pos + 5 < endOffset)
|
||||||
|
{
|
||||||
|
var cmd = ReadBE16(data, pos);
|
||||||
|
var x = ReadBE16(data, pos + 2);
|
||||||
|
var y = ReadBE16(data, pos + 4);
|
||||||
|
pos += 6;
|
||||||
|
|
||||||
|
if (System.Math.Abs(x) > 15000 || System.Math.Abs(y) > 15000)
|
||||||
|
break;
|
||||||
|
|
||||||
|
if (cmd < -1000)
|
||||||
|
break;
|
||||||
|
|
||||||
|
var type = cmd switch
|
||||||
|
{
|
||||||
|
1 => ChrPointType.Vertex,
|
||||||
|
4 => ChrPointType.Control,
|
||||||
|
5 => ChrPointType.EndPoint,
|
||||||
|
_ => ChrPointType.Vertex,
|
||||||
|
};
|
||||||
|
|
||||||
|
currentStroke.Add(new ChrStrokePoint(type, x, y));
|
||||||
|
|
||||||
|
if (type == ChrPointType.EndPoint)
|
||||||
|
{
|
||||||
|
if (currentStroke.Count > 0)
|
||||||
|
glyph.Strokes.Add(currentStroke);
|
||||||
|
currentStroke = new List<ChrStrokePoint>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentStroke.Count > 0)
|
||||||
|
glyph.Strokes.Add(currentStroke);
|
||||||
|
|
||||||
|
return glyph;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ReadBE16(byte[] data, int offset)
|
||||||
|
{
|
||||||
|
var val = (data[offset] << 8) | data[offset + 1];
|
||||||
|
if (val > 32767) val -= 65536;
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal enum ChrPointType
|
||||||
|
{
|
||||||
|
Vertex,
|
||||||
|
Control,
|
||||||
|
EndPoint,
|
||||||
|
}
|
||||||
|
|
||||||
|
internal struct ChrStrokePoint
|
||||||
|
{
|
||||||
|
public ChrPointType Type;
|
||||||
|
public double X;
|
||||||
|
public double Y;
|
||||||
|
|
||||||
|
public ChrStrokePoint(ChrPointType type, double x, double y)
|
||||||
|
{
|
||||||
|
Type = type;
|
||||||
|
X = x;
|
||||||
|
Y = y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ChrGlyph
|
||||||
|
{
|
||||||
|
internal readonly List<List<ChrStrokePoint>> Strokes = new();
|
||||||
|
|
||||||
|
public double AdvanceWidth { get; internal set; }
|
||||||
|
public double CapHeight { get; internal set; }
|
||||||
|
|
||||||
|
private const int ArcSamples = 16;
|
||||||
|
|
||||||
|
public List<Entity> ToEntities(double scale, double offsetX, double offsetY, Layer layer = null)
|
||||||
|
{
|
||||||
|
var entities = new List<Entity>();
|
||||||
|
layer ??= Layer.Default;
|
||||||
|
|
||||||
|
foreach (var stroke in Strokes)
|
||||||
|
{
|
||||||
|
if (stroke.Count < 2) continue;
|
||||||
|
|
||||||
|
var segments = BuildSegments(stroke);
|
||||||
|
foreach (var seg in segments)
|
||||||
|
{
|
||||||
|
if (seg.Points.Count < 2) continue;
|
||||||
|
|
||||||
|
var scaled = new List<Vector>(seg.Points.Count);
|
||||||
|
foreach (var pt in seg.Points)
|
||||||
|
scaled.Add(new Vector(pt.X * scale + offsetX, pt.Y * scale + offsetY));
|
||||||
|
|
||||||
|
var converted = PointsToLines(scaled);
|
||||||
|
|
||||||
|
foreach (var e in converted)
|
||||||
|
{
|
||||||
|
e.Layer = layer;
|
||||||
|
entities.Add(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entities;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Entity> PointsToLines(List<Vector> points)
|
||||||
|
{
|
||||||
|
var entities = new List<Entity>();
|
||||||
|
for (var i = 0; i < points.Count - 1; i++)
|
||||||
|
{
|
||||||
|
if (points[i].DistanceTo(points[i + 1]) < 0.001)
|
||||||
|
continue;
|
||||||
|
entities.Add(new Line(points[i], points[i + 1]));
|
||||||
|
}
|
||||||
|
return entities;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<StrokeSegment> BuildSegments(List<ChrStrokePoint> stroke)
|
||||||
|
{
|
||||||
|
var segments = new List<StrokeSegment>();
|
||||||
|
var current = new StrokeSegment();
|
||||||
|
|
||||||
|
var i = 0;
|
||||||
|
while (i < stroke.Count)
|
||||||
|
{
|
||||||
|
var pt = stroke[i];
|
||||||
|
|
||||||
|
if (pt.Type == ChrPointType.Vertex || pt.Type == ChrPointType.EndPoint)
|
||||||
|
{
|
||||||
|
if (i + 1 < stroke.Count && stroke[i + 1].Type == ChrPointType.Control)
|
||||||
|
{
|
||||||
|
var p0 = new Vector(pt.X, pt.Y);
|
||||||
|
var pMid = new Vector(stroke[i + 1].X, stroke[i + 1].Y);
|
||||||
|
var p2End = i + 2 < stroke.Count ? stroke[i + 2] : stroke[i + 1];
|
||||||
|
var p1 = new Vector(p2End.X, p2End.Y);
|
||||||
|
|
||||||
|
SampleCircularArc(current.Points, p0, pMid, p1, ArcSamples);
|
||||||
|
current.HasCurves = true;
|
||||||
|
i += 2;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
current.Points.Add(new Vector(pt.X, pt.Y));
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.Points.Count >= 2)
|
||||||
|
segments.Add(current);
|
||||||
|
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
|
|
||||||
|
private class StrokeSegment
|
||||||
|
{
|
||||||
|
public readonly List<Vector> Points = new();
|
||||||
|
public bool HasCurves;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SampleCircularArc(List<Vector> output, Vector p0, Vector pMid, Vector p1, int samples)
|
||||||
|
{
|
||||||
|
if (output.Count == 0 || output[^1].DistanceTo(p0) > 0.01)
|
||||||
|
output.Add(p0);
|
||||||
|
|
||||||
|
double ax = p0.X, ay = p0.Y;
|
||||||
|
double bx = pMid.X, by = pMid.Y;
|
||||||
|
double cx = p1.X, cy = p1.Y;
|
||||||
|
|
||||||
|
var d = 2 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by));
|
||||||
|
|
||||||
|
if (System.Math.Abs(d) < 1e-6)
|
||||||
|
{
|
||||||
|
output.Add(pMid);
|
||||||
|
output.Add(p1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var ux = ((ax * ax + ay * ay) * (by - cy) + (bx * bx + by * by) * (cy - ay) + (cx * cx + cy * cy) * (ay - by)) / d;
|
||||||
|
var uy = ((ax * ax + ay * ay) * (cx - bx) + (bx * bx + by * by) * (ax - cx) + (cx * cx + cy * cy) * (bx - ax)) / d;
|
||||||
|
var radius = System.Math.Sqrt((ax - ux) * (ax - ux) + (ay - uy) * (ay - uy));
|
||||||
|
|
||||||
|
var a0 = System.Math.Atan2(ay - uy, ax - ux);
|
||||||
|
var am = System.Math.Atan2(by - uy, bx - ux);
|
||||||
|
var a1 = System.Math.Atan2(cy - uy, cx - ux);
|
||||||
|
|
||||||
|
var ccwSweep = a1 - a0;
|
||||||
|
while (ccwSweep <= 0) ccwSweep += 2 * System.Math.PI;
|
||||||
|
|
||||||
|
var midRel = am - a0;
|
||||||
|
while (midRel < 0) midRel += 2 * System.Math.PI;
|
||||||
|
|
||||||
|
var sweep = midRel < ccwSweep ? ccwSweep : ccwSweep - 2 * System.Math.PI;
|
||||||
|
|
||||||
|
for (var i = 1; i <= samples; i++)
|
||||||
|
{
|
||||||
|
var t = (double)i / samples;
|
||||||
|
var angle = a0 + sweep * t;
|
||||||
|
output.Add(new Vector(ux + radius * System.Math.Cos(angle), uy + radius * System.Math.Sin(angle)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.IO;
|
||||||
|
|
||||||
|
namespace OpenNest.Tests.IO;
|
||||||
|
|
||||||
|
public class ChrFontTests
|
||||||
|
{
|
||||||
|
private ChrFont LoadFont()
|
||||||
|
{
|
||||||
|
var path = TestConfig.GetExistingPath("ChrFontPath");
|
||||||
|
Skip.If(path == null, "ChrFontPath not configured in test-config.json or file not found");
|
||||||
|
return ChrFont.Read(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public void Read_ParsesFontName()
|
||||||
|
{
|
||||||
|
var font = LoadFont();
|
||||||
|
Assert.Equal("US BLOCK 1L", font.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public void Read_ParsesVersion()
|
||||||
|
{
|
||||||
|
var font = LoadFont();
|
||||||
|
Assert.StartsWith("C1.", font.Version);
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public void Read_HasAsciiGlyphs()
|
||||||
|
{
|
||||||
|
var font = LoadFont();
|
||||||
|
Assert.True(font.HasGlyph('A'));
|
||||||
|
Assert.True(font.HasGlyph('Z'));
|
||||||
|
Assert.True(font.HasGlyph('0'));
|
||||||
|
Assert.True(font.HasGlyph(' '));
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public void Read_HasExtendedGlyphs()
|
||||||
|
{
|
||||||
|
var font = LoadFont();
|
||||||
|
Assert.True(font.HasGlyph(0xC7)); // C-cedilla
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public void Glyph_L_ProducesLines()
|
||||||
|
{
|
||||||
|
var font = LoadFont();
|
||||||
|
var glyph = font.GetGlyph('L');
|
||||||
|
Assert.NotNull(glyph);
|
||||||
|
|
||||||
|
var entities = glyph.ToEntities(1.0, 0, 0);
|
||||||
|
Assert.True(entities.Count >= 2, $"Expected at least 2 entities for 'L', got {entities.Count}");
|
||||||
|
Assert.All(entities, e => Assert.Equal(EntityType.Line, e.Type));
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public void Glyph_O_ProducesEntities()
|
||||||
|
{
|
||||||
|
var font = LoadFont();
|
||||||
|
var glyph = font.GetGlyph('O');
|
||||||
|
Assert.NotNull(glyph);
|
||||||
|
|
||||||
|
var entities = glyph.ToEntities(1.0, 0, 0);
|
||||||
|
Assert.True(entities.Count > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public void RenderText_ProducesEntities()
|
||||||
|
{
|
||||||
|
var font = LoadFont();
|
||||||
|
var entities = font.RenderText("HELLO", 1.0, new Vector(0, 0));
|
||||||
|
Assert.True(entities.Count > 0, "RenderText should produce entities");
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public void RenderText_ScalesCorrectly()
|
||||||
|
{
|
||||||
|
var font = LoadFont();
|
||||||
|
|
||||||
|
var small = font.RenderText("A", 0.5, Vector.Zero);
|
||||||
|
var large = font.RenderText("A", 2.0, Vector.Zero);
|
||||||
|
|
||||||
|
var smallBox = small.GetBoundingBox();
|
||||||
|
var largeBox = large.GetBoundingBox();
|
||||||
|
|
||||||
|
Assert.True(largeBox.Width > smallBox.Width);
|
||||||
|
Assert.True(largeBox.Length > smallBox.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public void RenderText_AdvancesCursor()
|
||||||
|
{
|
||||||
|
var font = LoadFont();
|
||||||
|
var abEntities = font.RenderText("AB", 1.0, Vector.Zero);
|
||||||
|
var aEntities = font.RenderText("A", 1.0, Vector.Zero);
|
||||||
|
|
||||||
|
var abBox = abEntities.GetBoundingBox();
|
||||||
|
var aBox = aEntities.GetBoundingBox();
|
||||||
|
|
||||||
|
Assert.True(abBox.Length > aBox.Length * 1.5,
|
||||||
|
$"AB width ({abBox.Length:F1}) should be significantly wider than A width ({aBox.Length:F1})");
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public void RenderText_MatchesGravographReference()
|
||||||
|
{
|
||||||
|
var font = LoadFont();
|
||||||
|
|
||||||
|
var height = 5.08;
|
||||||
|
var centerX = 50.8;
|
||||||
|
var centerY = 34.925;
|
||||||
|
|
||||||
|
var entities = font.RenderText("Text", height, Vector.Zero);
|
||||||
|
var rawBox = entities.GetBoundingBox();
|
||||||
|
var shiftX = centerX - (rawBox.Left + rawBox.Right) / 2;
|
||||||
|
var shiftY = centerY - (rawBox.Top + rawBox.Bottom) / 2;
|
||||||
|
foreach (var e in entities)
|
||||||
|
e.Offset(new Vector(shiftX, shiftY));
|
||||||
|
Assert.True(entities.Count > 0, "Should produce entities for 'Text'");
|
||||||
|
|
||||||
|
var box = entities.GetBoundingBox();
|
||||||
|
|
||||||
|
var refLeft = 43.53;
|
||||||
|
var refRight = 58.07;
|
||||||
|
var refBottom = 32.39;
|
||||||
|
var refTop = 37.47;
|
||||||
|
|
||||||
|
var tolerance = 0.5;
|
||||||
|
|
||||||
|
Assert.True(System.Math.Abs(box.Left - refLeft) < tolerance,
|
||||||
|
$"Left: ours={box.Left:F2}, ref={refLeft:F2}, diff={System.Math.Abs(box.Left - refLeft):F2}");
|
||||||
|
Assert.True(System.Math.Abs(box.Right - refRight) < tolerance,
|
||||||
|
$"Right: ours={box.Right:F2}, ref={refRight:F2}, diff={System.Math.Abs(box.Right - refRight):F2}");
|
||||||
|
Assert.True(System.Math.Abs(box.Bottom - refBottom) < tolerance,
|
||||||
|
$"Bottom: ours={box.Bottom:F2}, ref={refBottom:F2}, diff={System.Math.Abs(box.Bottom - refBottom):F2}");
|
||||||
|
Assert.True(System.Math.Abs(box.Top - refTop) < tolerance,
|
||||||
|
$"Top: ours={box.Top:F2}, ref={refTop:F2}, diff={System.Math.Abs(box.Top - refTop):F2}");
|
||||||
|
|
||||||
|
var actualCapHeight = box.Top - box.Bottom;
|
||||||
|
Assert.True(System.Math.Abs(actualCapHeight - height) < 0.5,
|
||||||
|
$"Cap height: ours={actualCapHeight:F2}, expected={height:F2}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public void MeasureTextWidth_IsConsistent()
|
||||||
|
{
|
||||||
|
var font = LoadFont();
|
||||||
|
var height = 5.08;
|
||||||
|
var measuredWidth = font.MeasureTextWidth("Text", height);
|
||||||
|
var entities = font.RenderText("Text", height, Vector.Zero);
|
||||||
|
var box = entities.GetBoundingBox();
|
||||||
|
|
||||||
|
Assert.True(measuredWidth >= box.Length,
|
||||||
|
$"Measured={measuredWidth:F2} should be >= rendered={box.Length:F2}");
|
||||||
|
Assert.True(measuredWidth - box.Length < 2.0,
|
||||||
|
$"Measured={measuredWidth:F2}, rendered={box.Length:F2}, diff={measuredWidth - box.Length:F2}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public void Glyph_t_HasCurveAtBottom()
|
||||||
|
{
|
||||||
|
var font = LoadFont();
|
||||||
|
var glyph = font.GetGlyph('t');
|
||||||
|
Assert.NotNull(glyph);
|
||||||
|
|
||||||
|
var entities = glyph.ToEntities(1.0, 0, 0);
|
||||||
|
var lines = entities.Cast<Line>().ToList();
|
||||||
|
|
||||||
|
Assert.True(lines.Count >= 10, $"Expected at least 10 entities for 't', got {lines.Count}");
|
||||||
|
|
||||||
|
var curveLines = lines.Skip(1).Take(lines.Count - 3).ToList();
|
||||||
|
Assert.True(curveLines.Count >= 14, $"Expected at least 14 curve segments, got {curveLines.Count}");
|
||||||
|
|
||||||
|
var lastCurve = curveLines[^1];
|
||||||
|
Assert.True(lastCurve.EndPoint.X > curveLines[0].StartPoint.X,
|
||||||
|
$"Curve should end to the right of where it starts: start X={curveLines[0].StartPoint.X:F1}, end X={lastCurve.EndPoint.X:F1}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
|
||||||
<PackageReference Include="xunit" Version="2.5.3" />
|
<PackageReference Include="xunit" Version="2.5.3" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
|
||||||
|
<PackageReference Include="Xunit.SkippableFact" Version="1.4.13" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -38,6 +39,9 @@
|
|||||||
<Content Include="Splitting\TestData\**\*">
|
<Content Include="Splitting\TestData\**\*">
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
</Content>
|
</Content>
|
||||||
|
<Content Include="test-config.json" Condition="Exists('test-config.json')">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</Content>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,8 +1,37 @@
|
|||||||
|
using System.Text.Json;
|
||||||
using OpenNest.CNC;
|
using OpenNest.CNC;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
namespace OpenNest.Tests;
|
namespace OpenNest.Tests;
|
||||||
|
|
||||||
|
internal static class TestConfig
|
||||||
|
{
|
||||||
|
private static readonly Lazy<Dictionary<string, string>> Config = new(() =>
|
||||||
|
{
|
||||||
|
var dir = AppContext.BaseDirectory;
|
||||||
|
for (var i = 0; i < 6; i++)
|
||||||
|
{
|
||||||
|
var path = Path.Combine(dir, "test-config.json");
|
||||||
|
if (File.Exists(path))
|
||||||
|
{
|
||||||
|
var json = File.ReadAllText(path);
|
||||||
|
return JsonSerializer.Deserialize<Dictionary<string, string>>(json) ?? new();
|
||||||
|
}
|
||||||
|
dir = Path.GetDirectoryName(dir)!;
|
||||||
|
}
|
||||||
|
return new();
|
||||||
|
});
|
||||||
|
|
||||||
|
public static string? Get(string key) =>
|
||||||
|
Config.Value.TryGetValue(key, out var val) ? val : null;
|
||||||
|
|
||||||
|
public static string? GetExistingPath(string key)
|
||||||
|
{
|
||||||
|
var path = Get(key);
|
||||||
|
return path != null && File.Exists(path) ? path : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
internal static class TestHelpers
|
internal static class TestHelpers
|
||||||
{
|
{
|
||||||
public static Part MakePartAt(double x, double y, double size = 1)
|
public static Part MakePartAt(double x, double y, double size = 1)
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ namespace OpenNest.Controls
|
|||||||
|
|
||||||
public event EventHandler<Line> LinePicked;
|
public event EventHandler<Line> LinePicked;
|
||||||
public event EventHandler PickCancelled;
|
public event EventHandler PickCancelled;
|
||||||
|
public event EventHandler<CadText> TextConvertRequested;
|
||||||
|
|
||||||
private bool isPickingBendLine;
|
private bool isPickingBendLine;
|
||||||
public bool IsPickingBendLine
|
public bool IsPickingBendLine
|
||||||
@@ -76,6 +77,13 @@ namespace OpenNest.Controls
|
|||||||
if (line != null)
|
if (line != null)
|
||||||
LinePicked?.Invoke(this, line);
|
LinePicked?.Invoke(this, line);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (e.Button == MouseButtons.Right)
|
||||||
|
{
|
||||||
|
var text = HitTestText(e.Location);
|
||||||
|
if (text != null)
|
||||||
|
ShowTextContextMenu(text, e.Location);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void OnPaint(PaintEventArgs e)
|
protected override void OnPaint(PaintEventArgs e)
|
||||||
@@ -328,6 +336,41 @@ namespace OpenNest.Controls
|
|||||||
return bestLine;
|
return bestLine;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private CadText HitTestText(Point controlPoint)
|
||||||
|
{
|
||||||
|
if (Texts == null || Texts.Count == 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var worldPoint = PointControlToWorld(controlPoint);
|
||||||
|
var tolerance = LengthGuiToWorld(8);
|
||||||
|
|
||||||
|
foreach (var text in Texts)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(text.Value))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var estimatedWidth = text.Height * text.Value.Length * 0.6;
|
||||||
|
var minX = text.Position.X - tolerance;
|
||||||
|
var maxX = text.Position.X + estimatedWidth + tolerance;
|
||||||
|
var minY = text.Position.Y - tolerance;
|
||||||
|
var maxY = text.Position.Y + text.Height + tolerance;
|
||||||
|
|
||||||
|
if (worldPoint.X >= minX && worldPoint.X <= maxX &&
|
||||||
|
worldPoint.Y >= minY && worldPoint.Y <= maxY)
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ShowTextContextMenu(CadText text, Point location)
|
||||||
|
{
|
||||||
|
var menu = new ContextMenuStrip();
|
||||||
|
var item = menu.Items.Add($"Convert \"{text.Value}\" to Geometry");
|
||||||
|
item.Click += (s, e) => TextConvertRequested?.Invoke(this, text);
|
||||||
|
menu.Show(this, location);
|
||||||
|
}
|
||||||
|
|
||||||
private void DrawEntityLabels(Graphics g)
|
private void DrawEntityLabels(Graphics g)
|
||||||
{
|
{
|
||||||
for (var i = 0; i < Entities.Count; i++)
|
for (var i = 0; i < Entities.Count; i++)
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ namespace OpenNest.Forms
|
|||||||
filterPanel.AddBendLineClicked += OnAddBendLineClicked;
|
filterPanel.AddBendLineClicked += OnAddBendLineClicked;
|
||||||
entityView1.LinePicked += OnLinePicked;
|
entityView1.LinePicked += OnLinePicked;
|
||||||
entityView1.PickCancelled += OnPickCancelled;
|
entityView1.PickCancelled += OnPickCancelled;
|
||||||
|
entityView1.TextConvertRequested += OnTextConvertRequested;
|
||||||
btnSplit.Click += OnSplitClicked;
|
btnSplit.Click += OnSplitClicked;
|
||||||
numQuantity.ValueChanged += OnQuantityChanged;
|
numQuantity.ValueChanged += OnQuantityChanged;
|
||||||
txtCustomer.TextChanged += OnCustomerChanged;
|
txtCustomer.TextChanged += OnCustomerChanged;
|
||||||
@@ -463,6 +464,115 @@ namespace OpenNest.Forms
|
|||||||
filterPanel.SetPickMode(false);
|
filterPanel.SetPickMode(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void OnTextConvertRequested(object sender, Controls.CadText text)
|
||||||
|
{
|
||||||
|
var item = CurrentItem;
|
||||||
|
if (item == null) return;
|
||||||
|
|
||||||
|
var font = LoadChrFont();
|
||||||
|
if (font == null) return;
|
||||||
|
|
||||||
|
var layer = new Geometry.Layer("ENGRAVE")
|
||||||
|
{
|
||||||
|
Color = System.Drawing.Color.Cyan,
|
||||||
|
IsVisible = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
var entities = font.RenderText(text.Value, text.Height, Geometry.Vector.Zero, layer);
|
||||||
|
if (entities.Count > 0)
|
||||||
|
{
|
||||||
|
var box = entities.GetBoundingBox();
|
||||||
|
var shiftX = text.HAlign switch
|
||||||
|
{
|
||||||
|
System.Drawing.StringAlignment.Center => text.Position.X - (box.Left + box.Right) / 2,
|
||||||
|
System.Drawing.StringAlignment.Far => text.Position.X - box.Right,
|
||||||
|
_ => text.Position.X - box.Left,
|
||||||
|
};
|
||||||
|
var shiftY = text.VAlign switch
|
||||||
|
{
|
||||||
|
System.Drawing.StringAlignment.Center => text.Position.Y - (box.Top + box.Bottom) / 2,
|
||||||
|
System.Drawing.StringAlignment.Near => text.Position.Y - box.Top,
|
||||||
|
_ => text.Position.Y - box.Bottom,
|
||||||
|
};
|
||||||
|
var shift = new Geometry.Vector(shiftX, shiftY);
|
||||||
|
foreach (var e in entities)
|
||||||
|
e.Offset(shift);
|
||||||
|
}
|
||||||
|
if (entities.Count == 0)
|
||||||
|
{
|
||||||
|
MessageBox.Show($"No geometry produced for \"{text.Value}\".", "Convert Text",
|
||||||
|
MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
item.Entities.AddRange(entities);
|
||||||
|
item.Texts.Remove(text);
|
||||||
|
item.EntityCount = item.Entities.Count;
|
||||||
|
item.Bounds = item.Entities.GetBoundingBox();
|
||||||
|
|
||||||
|
entityView1.Entities.Clear();
|
||||||
|
entityView1.Entities.AddRange(item.Entities);
|
||||||
|
entityView1.Texts = item.Texts;
|
||||||
|
filterPanel.LoadItem(item.Entities, item.Bends);
|
||||||
|
entityView1.Invalidate();
|
||||||
|
staleProgram = true;
|
||||||
|
lblEntityCount.Text = $"{item.EntityCount} entities";
|
||||||
|
}
|
||||||
|
|
||||||
|
private ChrFont cachedChrFont;
|
||||||
|
private string cachedChrFontPath;
|
||||||
|
|
||||||
|
private ChrFont LoadChrFont()
|
||||||
|
{
|
||||||
|
if (cachedChrFont != null)
|
||||||
|
return cachedChrFont;
|
||||||
|
|
||||||
|
// Look for .CHR files next to the app, then prompt
|
||||||
|
var appDir = System.IO.Path.GetDirectoryName(Application.ExecutablePath);
|
||||||
|
var candidates = Directory.GetFiles(appDir, "*.CHR", SearchOption.TopDirectoryOnly);
|
||||||
|
|
||||||
|
string fontPath;
|
||||||
|
if (candidates.Length == 1)
|
||||||
|
{
|
||||||
|
fontPath = candidates[0];
|
||||||
|
}
|
||||||
|
else if (candidates.Length > 1)
|
||||||
|
{
|
||||||
|
fontPath = PromptForChrFile(appDir);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
fontPath = PromptForChrFile(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fontPath == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
cachedChrFont = ChrFont.Read(fontPath);
|
||||||
|
cachedChrFontPath = fontPath;
|
||||||
|
return cachedChrFont;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
MessageBox.Show($"Error loading font: {ex.Message}", "Font Error",
|
||||||
|
MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string PromptForChrFile(string initialDir)
|
||||||
|
{
|
||||||
|
using var dlg = new OpenFileDialog
|
||||||
|
{
|
||||||
|
Title = "Select Engraving Font (.CHR)",
|
||||||
|
Filter = "Gravograph Font (*.CHR)|*.CHR",
|
||||||
|
InitialDirectory = initialDir ?? "",
|
||||||
|
};
|
||||||
|
return dlg.ShowDialog() == DialogResult.OK ? dlg.FileName : null;
|
||||||
|
}
|
||||||
|
|
||||||
private void OnDragEnter(object sender, DragEventArgs e)
|
private void OnDragEnter(object sender, DragEventArgs e)
|
||||||
{
|
{
|
||||||
if (e.Data.GetDataPresent(DataFormats.FileDrop))
|
if (e.Data.GetDataPresent(DataFormats.FileDrop))
|
||||||
|
|||||||
Reference in New Issue
Block a user