Compare commits

..
10 Commits
Author SHA1 Message Date
ajandClaude Fable 5 7f3beebe85 ci: add Gitea workflow to build and push image on main
Build PepApi image / build-and-push (push) Successful in 21s
Same auto-build setup as CncApi and LaserQuote.Web so all forge
services publish to the Gitea registry on push instead of the
manual docker save/load flow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 11:20:18 -04:00
aj 1d4f64726b fix: resolve 500s on nest-details endpoints under Docker deployment
NestReader opened .pep files with FileStream(path, FileMode.Open), which
defaults to FileAccess.ReadWrite. The forge deployment bind-mounts
/mnt/pep-nest read-only, so every read-only open was rejected with
"Read-only file system" (EROFS) even though the code never writes.
Pass FileAccess.Read/FileShare.Read explicitly, matching DrawingReader
and ZipHelper elsewhere in PepLib.Core.

Once that was fixed, a second pre-existing bug surfaced: GetNestDetailsAsync
matched DB rows by NestName AND a literal Path comparison built from the
container's local mount path. NestHeader.Path stores the original Windows
UNC share path (e.g. \REMCOSRV0\pep nest\) from when PepApi ran directly
against the network share, so the comparison can never match post-Docker.
Match by NestName alone (ordered by most recent), consistent with the
fallback lookup already used elsewhere in NestsController.
2026-06-30 15:00:22 -04:00
ajandClaude Sonnet 4.6 85f6a50f1a feat: improve DXF export and cross-platform file handling
- PepSettings: add DrawingsDirectory property for DXF endpoint config
- DrawingDxfExporter: support subprogram calls, skip non-cut linear moves,
  remove spurious radians-to-degrees conversion on arc angles, set AC1018 DXF version
- DrawingReader: handle .loop-info/.cadsnapshot entries gracefully, derive
  DrawingInfo from loop names when .info entry is absent, use FileShare.Read
- DrawingInfoReader: use TryParse instead of Parse for material number
- Drawing: make GetLoopName public (used by DrawingDxfExporter)
- ZipHelper: use FileShare.Read to allow concurrent readers on Windows/Linux
- NestFilterData: skip status filter when value is "All"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 07:23:12 -04:00
ajandClaude Sonnet 4.6 5e0856b5d1 fix: resolve API route name collisions
- DrawingsController: rename 'name/{name}' to 'by-name/{name}' to
  eliminate ambiguous match with '{name}/dxf' on path /drawings/name/dxf
- NestsController: add regex constraint to '{nestName}/download' and
  '{nestName}/plates' to prevent collision with year-parameterized routes
  when nestName is a 4-digit integer
- PepMcp/PepTools.cs: update GetDrawingByName URL to match renamed route

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 07:20:53 -04:00
aj 9fcc9df209 feat: add Dockerfile for Linux container build 2026-06-25 22:17:43 -04:00
aj b4bdcc160e feat: make PepApi cross-platform for Linux Docker 2026-06-25 22:15:20 -04:00
aj 5a15b552a3 feat: add compare_geometry MCP tool to PepMcp
- Add LaserQuoteBaseUrl config property to PepTools
- Implement CompareGeometry() MCP tool to compare part geometry between PEP and LaserQuote
- Read LaserQuoteBaseUrl from appsettings.json in Program.cs
- Add appsettings.json with LaserQuoteBaseUrl config
- Update PepMcp.csproj to copy appsettings.json to output directory
2026-06-23 07:17:22 -04:00
aj f59c5bd32e feat: add GET /drawings/{name}/dxf endpoint to export PEP drawings as DXF 2026-06-23 06:57:08 -04:00
ajandClaude Sonnet 4.6 7a4a35da55 fix: convert arc angles from radians to degrees in DrawingDxfExporter
ACadSharp Arc.StartAngle/EndAngle store values in degrees (DXF spec groups
50/51). The previous code passed Math.Atan2 results (radians) directly,
causing silent geometry corruption. Also hardened the arc export test to
assert angles are in degree range (0..360).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 06:53:17 -04:00
aj af07946a79 feat: add DrawingDxfExporter to convert PEP Drawing to DXF stream 2026-06-23 06:49:50 -04:00
23 changed files with 507 additions and 27 deletions
+4
View File
@@ -0,0 +1,4 @@
**/.git
**/obj
**/bin
*.md
+28
View File
@@ -0,0 +1,28 @@
name: Build PepApi image
on:
push:
branches: [main]
paths:
- "PepApi.Core/**"
- "PepLib.Core/**"
- "Dockerfile"
- ".gitea/workflows/build-pepapi.yml"
workflow_dispatch: {}
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Log in to Gitea container registry
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.thecozycat.net -u "${{ gitea.actor }}" --password-stdin
- name: Build image
run: docker build -t git.thecozycat.net/${{ gitea.repository_owner }}/pepapi:latest -t git.thecozycat.net/${{ gitea.repository_owner }}/pepapi:${{ gitea.sha }} .
- name: Push image
run: |
docker push git.thecozycat.net/${{ gitea.repository_owner }}/pepapi:latest
docker push git.thecozycat.net/${{ gitea.repository_owner }}/pepapi:${{ gitea.sha }}
+19
View File
@@ -0,0 +1,19 @@
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY PepApi.Core/PepApi.Core.csproj PepApi.Core/
COPY PepLib.Core/PepLib.Core.csproj PepLib.Core/
RUN dotnet restore PepApi.Core/PepApi.Core.csproj
COPY PepApi.Core/ PepApi.Core/
COPY PepLib.Core/ PepLib.Core/
RUN dotnet publish PepApi.Core/PepApi.Core.csproj -c Release -o /app/publish
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final
WORKDIR /app
COPY --from=build /app/publish .
ENV ASPNETCORE_URLS=http://+:8085
ENTRYPOINT ["dotnet", "PepApi.Core.dll"]
+1
View File
@@ -4,4 +4,5 @@ public class PepSettings
{
public string NestDirectory { get; set; } = string.Empty;
public string MaterialsFile { get; set; } = string.Empty;
public string DrawingsDirectory { get; set; } = string.Empty;
}
+42 -2
View File
@@ -1,7 +1,10 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using PepApi.Core.Configuration;
using PepApi.Core.Models;
using PepLib.Data;
using PepLib.IO;
namespace PepApi.Core.Controllers;
@@ -10,10 +13,12 @@ namespace PepApi.Core.Controllers;
public class DrawingsController : ControllerBase
{
private readonly PepDB _db;
private readonly PepSettings _settings;
public DrawingsController(PepDB db)
public DrawingsController(PepDB db, IOptions<PepSettings> settings)
{
_db = db;
_settings = settings.Value;
}
/// <summary>
@@ -80,7 +85,7 @@ public class DrawingsController : ControllerBase
/// <summary>
/// Get a drawing by exact name.
/// </summary>
[HttpGet("name/{name}")]
[HttpGet("by-name/{name}")]
public async Task<ActionResult<DrawingDetails>> GetDrawingByName(string name)
{
var drawing = await _db.Drawings
@@ -147,6 +152,41 @@ public class DrawingsController : ControllerBase
});
}
/// <summary>
/// Export a drawing as a DXF file.
/// </summary>
[HttpGet("{name}/dxf")]
public async Task<IActionResult> GetDrawingDxf(string name)
{
var drawing = await _db.Drawings
.Where(d => d.Name.ToUpper() == name.ToUpper())
.FirstOrDefaultAsync();
if (drawing == null)
return NotFound(new { message = "Drawing not found in database" });
if (string.IsNullOrEmpty(_settings.DrawingsDirectory))
return StatusCode(503, new { message = "DrawingsDirectory is not configured" });
var filePath = Path.Combine(_settings.DrawingsDirectory, drawing.File);
if (!System.IO.File.Exists(filePath))
return NotFound(new { message = $"Drawing file not found: {drawing.File}" });
PepLib.Models.Drawing pep;
try
{
pep = PepLib.Models.Drawing.Load(filePath);
}
catch (Exception ex)
{
return StatusCode(500, new { message = $"Failed to load drawing: {ex.Message}" });
}
var stream = DrawingDxfExporter.Export(pep);
return File(stream, "application/dxf", $"{name}.dxf");
}
private static DrawingDetails ConvertToDetails(Drawing drawing)
{
return new DrawingDetails
+34 -7
View File
@@ -101,7 +101,7 @@ public class NestsController : ControllerBase
return File(bytes, mimeType, fileName);
}
[HttpGet("{nestName}/download")]
[HttpGet("{nestName:regex(^(?!\\d{{4}}$).+)}/download")]
public async Task<IActionResult> DownloadFile(string nestName)
{
var filePath = await GetNestPathAsync(nestName);
@@ -131,7 +131,7 @@ public class NestsController : ControllerBase
return Ok(combined);
}
[HttpGet("{nestName}/plates")]
[HttpGet("{nestName:regex(^(?!\\d{{4}}$).+)}/plates")]
public async Task<ActionResult<List<Plate>>> GetPlates(string nestName)
{
var nestFile = await GetNestPathAsync(nestName);
@@ -346,13 +346,22 @@ public class NestsController : ControllerBase
var pepPath = Path.Combine(_nestDirectory, nestName + ".pep");
if (System.IO.File.Exists(pepPath))
return pepPath;
var pepMatch = FindFileByPrefix(_nestDirectory, nestName, ".pep");
if (pepMatch != null)
return pepMatch;
}
// Older nests use .zip in year subdirectory
var zipPath = Path.Combine(_nestDirectory, year.Value.ToString(), nestName + ".zip");
var yearDir = Path.Combine(_nestDirectory, year.Value.ToString());
var zipPath = Path.Combine(yearDir, nestName + ".zip");
if (System.IO.File.Exists(zipPath))
return zipPath;
var zipMatch = FindFileByPrefix(yearDir, nestName, ".zip");
if (zipMatch != null)
return zipMatch;
return null;
}
@@ -361,33 +370,51 @@ public class NestsController : ControllerBase
if (System.IO.File.Exists(flatPepPath))
return flatPepPath;
var flatPepMatch = FindFileByPrefix(_nestDirectory, nestName, ".pep");
if (flatPepMatch != null)
return flatPepMatch;
// Fall back to year-based directory lookup for older nests
var upperName = nestName.ToUpper();
var nestHeader = await _db.NestHeaders
.Where(n => n.NestName.ToUpper() == nestName.ToUpper() && n.DateProgrammed != null)
.Where(n => (n.NestName.ToUpper() == upperName || n.NestName.ToUpper().StartsWith(upperName + "-"))
&& n.DateProgrammed != null)
.OrderByDescending(n => n.DateProgrammed)
.FirstOrDefaultAsync();
if (nestHeader == null)
return null;
var fullName = nestHeader.NestName;
var dbYear = nestHeader.DateProgrammed!.Value.Year;
// Older nests used .zip extension
var yearZipPath = Path.Combine(_nestDirectory, dbYear.ToString(), nestName + ".zip");
var yearZipPath = Path.Combine(_nestDirectory, dbYear.ToString(), fullName + ".zip");
if (System.IO.File.Exists(yearZipPath))
return yearZipPath;
return null;
}
private static string? FindFileByPrefix(string directory, string prefix, string extension)
{
if (!Directory.Exists(directory))
return null;
var pattern = prefix + "-*" + extension;
var matches = Directory.GetFiles(directory, pattern);
return matches.Length > 0 ? matches.Order().First() : null;
}
private async Task<NestDetails> GetNestDetailsAsync(string nestFilePath)
{
var nest = Nest.Load(nestFilePath);
var dir = Path.GetDirectoryName(nestFilePath) + "\\";
var name = Path.GetFileNameWithoutExtension(nestFilePath).ToUpper();
var info = await _db.NestHeaders
.FirstOrDefaultAsync(n => n.NestName.ToUpper() == name && dir == n.Path);
.Where(n => n.NestName.ToUpper() == name)
.OrderByDescending(n => n.DateProgrammed)
.FirstOrDefaultAsync();
if (info == null)
throw new Exception("Nest header not found in database");
+1 -1
View File
@@ -77,7 +77,7 @@ namespace PepApi.Core.Models
nests = nests.Where(n => n.DateCreated <= this.EndDate);
}
if (this.Status != null)
if (this.Status != null && !this.Status.Equals("All", StringComparison.OrdinalIgnoreCase))
{
var x = this.Status.ToUpper();
nests = nests.Where(n => n.Status.ToUpper() == x);
+3 -2
View File
@@ -6,8 +6,9 @@ var builder = WebApplication.CreateBuilder(args);
// Enable Windows Service hosting
builder.Host.UseWindowsService();
// Enable Windows Service hosting (only on Windows)
if (OperatingSystem.IsWindows())
builder.Host.UseWindowsService();
// Add services to the container
builder.Services.AddControllers()
+14
View File
@@ -9,6 +9,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PepLib.Core", "PepLib.Core\
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PepMcp", "PepMcp\PepMcp.csproj", "{28899B7B-2185-4141-B348-E227DFB355E9}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PepLib.Core.Tests", "PepLib.Core.Tests\PepLib.Core.Tests.csproj", "{FF7E4CB2-8825-4A03-9D0C-CC713BD93A18}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -55,6 +57,18 @@ Global
{28899B7B-2185-4141-B348-E227DFB355E9}.Release|x64.Build.0 = Release|Any CPU
{28899B7B-2185-4141-B348-E227DFB355E9}.Release|x86.ActiveCfg = Release|Any CPU
{28899B7B-2185-4141-B348-E227DFB355E9}.Release|x86.Build.0 = Release|Any CPU
{FF7E4CB2-8825-4A03-9D0C-CC713BD93A18}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{FF7E4CB2-8825-4A03-9D0C-CC713BD93A18}.Debug|Any CPU.Build.0 = Debug|Any CPU
{FF7E4CB2-8825-4A03-9D0C-CC713BD93A18}.Debug|x64.ActiveCfg = Debug|Any CPU
{FF7E4CB2-8825-4A03-9D0C-CC713BD93A18}.Debug|x64.Build.0 = Debug|Any CPU
{FF7E4CB2-8825-4A03-9D0C-CC713BD93A18}.Debug|x86.ActiveCfg = Debug|Any CPU
{FF7E4CB2-8825-4A03-9D0C-CC713BD93A18}.Debug|x86.Build.0 = Debug|Any CPU
{FF7E4CB2-8825-4A03-9D0C-CC713BD93A18}.Release|Any CPU.ActiveCfg = Release|Any CPU
{FF7E4CB2-8825-4A03-9D0C-CC713BD93A18}.Release|Any CPU.Build.0 = Release|Any CPU
{FF7E4CB2-8825-4A03-9D0C-CC713BD93A18}.Release|x64.ActiveCfg = Release|Any CPU
{FF7E4CB2-8825-4A03-9D0C-CC713BD93A18}.Release|x64.Build.0 = Release|Any CPU
{FF7E4CB2-8825-4A03-9D0C-CC713BD93A18}.Release|x86.ActiveCfg = Release|Any CPU
{FF7E4CB2-8825-4A03-9D0C-CC713BD93A18}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -0,0 +1,67 @@
using PepLib.Codes;
using PepLib.Enums;
using PepLib.Geometry;
using PepLib.IO;
using PepLib.Models;
using Xunit;
namespace PepLib.Core.Tests.IO;
public class DrawingDxfExporterTests
{
[Fact]
public void Export_RectangularLoop_ProducesFourLines()
{
// Arrange: a square 10x10 in incremental mode
var drawing = new Drawing();
drawing.Info = new DrawingInfo { Name = "TEST" };
var loop = new Loop { Name = "TEST.loop-000" };
// Mode defaults to Incremental in Loop constructor
loop.Add(new RapidMove(new Vector(0, 0))); // position at (0,0)
loop.Add(new LinearMove(new Vector(10, 0))); // → (10,0)
loop.Add(new LinearMove(new Vector(0, 10))); // → (10,10)
loop.Add(new LinearMove(new Vector(-10, 0))); // → (0,10)
loop.Add(new LinearMove(new Vector(0, -10))); // → (0,0)
drawing.Loops.Add(loop);
// Act
using var stream = DrawingDxfExporter.Export(drawing);
// Assert: stream is non-empty and contains 4 line entities
Assert.True(stream.Length > 0);
stream.Position = 0;
using var reader = new ACadSharp.IO.DxfReader(stream);
var doc = reader.Read();
var lines = doc.Entities.OfType<ACadSharp.Entities.Line>().ToList();
var arcs = doc.Entities.OfType<ACadSharp.Entities.Arc>().ToList();
Assert.Equal(4, lines.Count);
Assert.Empty(arcs);
}
[Fact]
public void Export_LoopWithArc_ProducesLineAndArc()
{
var drawing = new Drawing();
drawing.Info = new DrawingInfo { Name = "TEST" };
var loop = new Loop { Name = "TEST.loop-000" };
loop.Add(new RapidMove(new Vector(5, 0))); // start at (5,0)
loop.Add(new LinearMove(new Vector(5, 0))); // → (10,0)
loop.Add(new CircularMove(new Vector(-5, 5), new Vector(-5, 0), RotationType.CCW)); // arc: end=(5,5), center=(5,0)
drawing.Loops.Add(loop);
using var stream = DrawingDxfExporter.Export(drawing);
stream.Position = 0;
using var reader = new ACadSharp.IO.DxfReader(stream);
var doc = reader.Read();
var lines = doc.Entities.OfType<ACadSharp.Entities.Line>().ToList();
var arcs = doc.Entities.OfType<ACadSharp.Entities.Arc>().ToList();
Assert.Single(lines);
Assert.Single(arcs);
var arc = doc.Entities.OfType<ACadSharp.Entities.Arc>().Single();
// Angles must be in degrees (0..360), not radians (0..6.28)
Assert.InRange(arc.StartAngle, 0, 360);
Assert.InRange(arc.EndAngle, 0, 360);
}
}
+33
View File
@@ -0,0 +1,33 @@
using PepLib.IO;
using Xunit;
namespace PepLib.Core.Tests.IO;
public class NestReaderTests
{
[Fact]
public void Read_ReadOnlyFile_DoesNotRequestWriteAccess()
{
// Arrange: a file on a read-only mount denies write-access opens (EROFS on Linux,
// UnauthorizedAccessException on Windows when the read-only attribute is set).
// NestReader only ever reads nest files, so it must never request FileAccess.Write.
var path = Path.GetTempFileName();
try
{
File.SetAttributes(path, FileAttributes.ReadOnly);
var reader = new NestReader();
// Act / Assert: opening must succeed (i.e. not throw because of the read-only
// attribute). Parsing the empty/invalid content is expected to fail separately.
var ex = Record.Exception(() => reader.Read(path));
Assert.IsNotType<UnauthorizedAccessException>(ex);
}
finally
{
File.SetAttributes(path, FileAttributes.Normal);
File.Delete(path);
}
}
}
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="ACadSharp" Version="3.1.32" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\PepLib.Core\PepLib.Core.csproj" />
</ItemGroup>
</Project>
+148
View File
@@ -0,0 +1,148 @@
using ACadSharp;
using ACadSharp.IO;
using ACadSharp.Tables;
using CSMath;
using PepLib.Codes;
using PepLib.Enums;
using PepLib.Geometry;
using PepLib.Models;
using AcadArc = ACadSharp.Entities.Arc;
using AcadCircle = ACadSharp.Entities.Circle;
using AcadLine = ACadSharp.Entities.Line;
namespace PepLib.IO;
public static class DrawingDxfExporter
{
public static Stream Export(Drawing drawing)
{
var doc = new CadDocument();
doc.Header.Version = ACadVersion.AC1018;
// Collect loop IDs that are called as subprograms so we skip drawing them as top-level
var subprogramIds = new HashSet<int>();
foreach (var loop in drawing.Loops)
{
foreach (var code in loop)
{
if (code.CodeType() == CodeType.SubProgramCall)
subprogramIds.Add(((SubProgramCall)code).LoopId);
}
}
foreach (var loop in drawing.Loops)
{
var loopNumber = GetLoopNumber(loop.Name);
if (subprogramIds.Contains(loopNumber))
continue; // drawn inline at SubProgramCall positions
var layer = new Layer(loop.Name);
doc.Layers.Add(layer);
DrawLoop(doc, drawing, loop, layer, new Vector());
}
var buffer = new MemoryStream();
using (var writer = new DxfWriter(buffer, doc, false))
{
writer.Write();
}
return new MemoryStream(buffer.ToArray());
}
private static Vector DrawLoop(CadDocument doc, Drawing drawing, Loop loop, Layer layer, Vector startPos)
{
var pos = startPos;
foreach (var code in loop)
{
switch (code.CodeType())
{
case CodeType.RapidMove:
pos = Advance(pos, ((RapidMove)code).EndPoint, loop.Mode);
break;
case CodeType.LinearMove:
var lm = (LinearMove)code;
if (lm.Type == EntityType.Cut)
{
var lineEnd = Advance(pos, lm.EndPoint, loop.Mode);
doc.Entities.Add(new AcadLine
{
StartPoint = ToXYZ(pos),
EndPoint = ToXYZ(lineEnd),
Layer = layer
});
pos = lineEnd;
}
else
{
pos = Advance(pos, lm.EndPoint, loop.Mode);
}
break;
case CodeType.CircularMove:
var cm = (CircularMove)code;
var arcEnd = Advance(pos, cm.EndPoint, loop.Mode);
var arcCenter = Advance(pos, cm.CenterPoint, loop.Mode);
var dx = pos.X - arcCenter.X;
var dy = pos.Y - arcCenter.Y;
var radius = Math.Sqrt(dx * dx + dy * dy);
var startAngle = Math.Atan2(pos.Y - arcCenter.Y, pos.X - arcCenter.X);
var endAngle = Math.Atan2(arcEnd.Y - arcCenter.Y, arcEnd.X - arcCenter.X);
if (cm.Rotation == RotationType.CW)
(startAngle, endAngle) = (endAngle, startAngle);
if (Math.Abs(startAngle - endAngle) < 1e-10)
{
doc.Entities.Add(new AcadCircle
{
Center = ToXYZ(arcCenter),
Radius = radius,
Layer = layer
});
}
else
{
doc.Entities.Add(new AcadArc
{
Center = ToXYZ(arcCenter),
Radius = radius,
StartAngle = startAngle,
EndAngle = endAngle,
Layer = layer
});
}
pos = arcEnd;
break;
case CodeType.SubProgramCall:
var call = (SubProgramCall)code;
var subLoop = drawing.Loops.FirstOrDefault(l => l.Name == drawing.GetLoopName(call.LoopId));
if (subLoop != null)
DrawLoop(doc, drawing, subLoop, layer, pos);
// pos unchanged — subprograms are closed shapes that return to start
break;
}
}
return pos;
}
private static int GetLoopNumber(string loopName)
{
var idx = loopName.LastIndexOf(".loop-", StringComparison.OrdinalIgnoreCase);
if (idx < 0) return -1;
return int.TryParse(loopName.Substring(idx + 6), out var n) ? n : -1;
}
private static Vector Advance(Vector current, Vector offset, ProgrammingMode mode) =>
mode == ProgrammingMode.Incremental ? current + offset : offset;
private static XYZ ToXYZ(Vector v) => new XYZ(v.X, v.Y, 0);
}
+2 -1
View File
@@ -34,7 +34,8 @@ namespace PepLib.IO
stream.Seek(0x9, SeekOrigin.Current);
Info.MaterialNumber = int.Parse(ReadString(0x40, ref stream));
int.TryParse(ReadString(0x40, ref stream), out var materialNumber);
Info.MaterialNumber = materialNumber;
Info.MaterialGrade = ReadString(0x10, ref stream);
Info.ProgrammedBy = ReadString(0x40, ref stream);
Info.CreatedBy = ReadString(0x40, ref stream);
+21 -7
View File
@@ -1,5 +1,4 @@
using PepLib.Models;
using System.Diagnostics;
using System.IO.Compression;
using System.Text.RegularExpressions;
@@ -38,6 +37,11 @@ namespace PepLib.IO
LoadInfo(memstream);
memstream.Close();
continue;
case ".loop-info":
case ".cadsnapshot":
memstream.Close();
continue;
}
if (Regex.IsMatch(extension, "loop-\\d\\d\\d"))
@@ -47,6 +51,9 @@ namespace PepLib.IO
}
}
if (Drawing.Info == null)
Drawing.Info = DeriveInfoFromLoops();
Drawing.ResolveLoops();
}
@@ -62,7 +69,7 @@ namespace PepLib.IO
try
{
stream = new FileStream(nestFile, FileMode.Open);
stream = new FileStream(nestFile, FileMode.Open, FileAccess.Read, FileShare.Read);
Read(stream);
}
finally
@@ -73,16 +80,23 @@ namespace PepLib.IO
}
private void LoadInfo(Stream stream)
{
try
{
Drawing.Info = DrawingInfo.Load(stream);
}
catch (Exception exception)
private DrawingInfo DeriveInfoFromLoops()
{
Debug.WriteLine(exception.Message);
Debug.WriteLine(exception.StackTrace);
var info = new DrawingInfo();
if (Drawing.Loops.Count > 0)
{
// Loop names follow the pattern "{DrawingName}.loop-XXX"
var loopName = Drawing.Loops[0].Name;
var suffix = loopName.LastIndexOf(".loop-", StringComparison.OrdinalIgnoreCase);
info.Name = suffix >= 0 ? loopName.Substring(0, suffix) : loopName;
}
return info;
}
private Loop ReadLoop(string name, Stream stream)
+1 -1
View File
@@ -86,7 +86,7 @@ namespace PepLib.IO
try
{
stream = new FileStream(nestFile, FileMode.Open);
stream = new FileStream(nestFile, FileMode.Open, FileAccess.Read, FileShare.Read);
Read(stream);
}
finally
+1 -1
View File
@@ -76,7 +76,7 @@ namespace PepLib.Models
return null;
}
private string GetLoopName(int loopId)
public string GetLoopName(int loopId)
{
return string.Format("{0}.loop-{1}", Info.Name, loopId.ToString().PadLeft(3, '0'));
}
+1
View File
@@ -7,6 +7,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ACadSharp" Version="3.1.32" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.10">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
+2 -2
View File
@@ -18,7 +18,7 @@ namespace PepLib.Utilities
var nameList = new List<string>();
var streamList = new List<Stream>();
using (var fileStream = new FileStream(file, FileMode.Open, FileAccess.Read))
using (var fileStream = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.Read))
using (var zip = new ZipArchive(fileStream, ZipArchiveMode.Read))
{
foreach (var entry in zip.Entries)
@@ -52,7 +52,7 @@ namespace PepLib.Utilities
/// <returns></returns>
public static bool ExtractByExtension(string file, string extension, out string name, out Stream stream)
{
using (var fileStream = new FileStream(file, FileMode.Open, FileAccess.Read))
using (var fileStream = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.Read))
using (var zip = new ZipArchive(fileStream, ZipArchiveMode.Read))
{
foreach (var entry in zip.Entries)
+6
View File
@@ -12,4 +12,10 @@
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
</ItemGroup>
<ItemGroup>
<Content Include="appsettings.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>
+48 -1
View File
@@ -10,6 +10,9 @@ public class PepTools
BaseAddress = new Uri("http://localhost:8085")
};
public static string LaserQuoteBaseUrl { get; set; } = "http://localhost:5260";
private static readonly HttpClient _laserQuoteClient = new();
private static readonly JsonSerializerOptions _jsonOptions = new()
{
PropertyNameCaseInsensitive = true,
@@ -530,7 +533,7 @@ public class PepTools
{
try
{
var response = await _httpClient.GetAsync($"/drawings/name/{Uri.EscapeDataString(name)}");
var response = await _httpClient.GetAsync($"/drawings/by-name/{Uri.EscapeDataString(name)}");
if (!response.IsSuccessStatusCode)
return $"Error: {response.StatusCode} - {await response.Content.ReadAsStringAsync()}";
@@ -571,4 +574,48 @@ public class PepTools
return $"Error calling PEP API: {ex.Message}";
}
}
[McpServerTool, Description("Compare the geometry of a part between PEP (nesting software) and LaserQuote (quoting software). Returns bounding box dimensions, cut length, entity counts, pixel diff %, and alignment strategy. Use this to detect when part drawings have drifted between systems.")]
public static async Task<string> CompareGeometry(
[Description("The part number to compare (e.g., 'SULLYS-003')")] string partNumber)
{
try
{
var url = $"{LaserQuoteBaseUrl}/api/geometry/compare?partNumber={Uri.EscapeDataString(partNumber)}";
var response = await _laserQuoteClient.GetAsync(url);
var json = await response.Content.ReadAsStringAsync();
var result = System.Text.Json.JsonSerializer.Deserialize<GeometryCompareResult>(json, _jsonOptions);
if (result?.Error != null)
return $"Note: {result.Error}";
var sb = new System.Text.StringBuilder();
sb.AppendLine($"Geometry Comparison: {partNumber}");
sb.AppendLine($"Result: {(result?.IsMatch == true ? "MATCH " : "MISMATCH ")}");
sb.AppendLine($"Pixel Diff: {result?.PixelDiffPercent:F3}%");
sb.AppendLine($"Alignment: {result?.AlignmentLabel}");
sb.AppendLine();
sb.AppendLine("| Metric | LaserQuote | PEP |");
sb.AppendLine("|---------------|-----------------|-----------------|");
sb.AppendLine($"| Width (in) | {result?.LaserQuote?.Width,15:F4} | {result?.Pep?.Width,15:F4} |");
sb.AppendLine($"| Height (in) | {result?.LaserQuote?.Height,15:F4} | {result?.Pep?.Height,15:F4} |");
sb.AppendLine($"| Cut Length | {result?.LaserQuote?.CutLength,15:F3} | {result?.Pep?.CutLength,15:F3} |");
sb.AppendLine($"| Entity Count | {result?.LaserQuote?.EntityCount,15} | {result?.Pep?.EntityCount,15} |");
return sb.ToString();
}
catch (Exception ex)
{
return $"Error calling LaserQuote API: {ex.Message}";
}
}
private record GeometryMetricDto(double Width, double Height, double CutLength, int EntityCount);
private record GeometryCompareResult(
bool IsMatch,
GeometryMetricDto? LaserQuote,
GeometryMetricDto? Pep,
double PixelDiffPercent,
string? AlignmentLabel,
string? Error);
}
+3
View File
@@ -8,4 +8,7 @@ builder.Services
.WithTools<PepTools>();
var app = builder.Build();
PepTools.LaserQuoteBaseUrl = builder.Configuration["LaserQuoteBaseUrl"] ?? "http://localhost:5260";
await app.RunAsync();
+3
View File
@@ -0,0 +1,3 @@
{
"LaserQuoteBaseUrl": "http://localhost:5260"
}