feat: move revision tracking to ExportRecord, add perceptual hash comparison, cut list modal, and auto-start API

- Move Revision from Drawing to ExportRecord so each export captures its own revision snapshot
- Add Hamming distance comparison for perceptual hashes (tolerance of 10 bits) to avoid false revision bumps
- Replace CoenM.ImageHash with inline DifferenceHash impl (compatible with ImageSharp 3.x)
- Increase PDF render DPI from 72 to 150 for better hash fidelity
- Add download-dxfs-by-drawing endpoint for cross-export DXF zip downloads
- Prefix DXF filenames with equipment number when no drawing number is present
- Pass original filename to storage service for standalone part exports
- Auto-start FabWorks.Api from ExportDXF client if not already running
- Add cut list modal with copy-to-clipboard in the web UI
- Update PDF hash on existing export records after upload
- Bump static asset cache versions to v3

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-25 15:48:28 -05:00
parent f6cd91f1b5
commit b9e84de7c0
20 changed files with 803 additions and 50 deletions

View File

@@ -2,7 +2,45 @@
"permissions": {
"allow": [
"Bash(git add:*)",
"Bash(git commit:*)"
"Bash(git commit:*)",
"Bash(dotnet build:*)",
"Bash(msbuild:*)",
"Bash(powershell.exe -File \"C:\\Users\\aisaacs\\.claude\\skills\\roslyn-bridge\\scripts\\rb.ps1\" getprojects)",
"Bash(powershell -Command \"& C:\\Users\\aisaacs\\.claude\\skills\\roslyn-bridge\\scripts\\rb.ps1 instances\")",
"Bash(powershell -Command \"& C:\\Users\\aisaacs\\.claude\\skills\\roslyn-bridge\\scripts\\rb.ps1 build -ProjectName ExportDXF\")",
"Bash(powershell -Command \"& C:\\Users\\aisaacs\\.claude\\skills\\roslyn-bridge\\scripts\\rb.ps1 projects\")",
"Bash(powershell -Command \"& C:\\Users\\aisaacs\\.claude\\skills\\roslyn-bridge\\scripts\\rb.ps1 errors\")",
"Bash(powershell -Command:*)",
"Bash(curl:*)",
"Bash(python:*)",
"Skill(roslyn-bridge)",
"Bash(dotnet add:*)",
"Bash(nuget restore:*)",
"mcp__RoslynBridge__get_solution_overview",
"mcp__RoslynBridge__get_files",
"mcp__RoslynBridge__list_instances",
"mcp__RoslynBridge__refresh_workspace",
"mcp__RoslynBridge__get_diagnostics",
"mcp__RoslynBridge__build_project",
"mcp__RoslynBridge__get_projects",
"Bash(findstr:*)",
"Bash(Select-String -Pattern \"error|Build succeeded\")",
"Bash(Select-String -NotMatch \"CA1416\")",
"Bash(dir:*)",
"Bash(git checkout:*)",
"Bash(ls \"C:\\\\Users\\\\aisaacs\\\\Desktop\\\\Projects\\\\ExportDXF\\\\docs\\\\plans\"\" 2>nul || echo \"No plans dir \")",
"Bash(dotnet new:*)",
"Bash(dotnet sln:*)",
"Bash(del \"C:\\\\Users\\\\aisaacs\\\\Desktop\\\\Projects\\\\ExportDXF\\\\FabWorks.Core\\\\Class1.cs\")",
"Bash(copy \"S:\\\\4980 GMCH Lockport\\\\4980 A05 Degreaser Casing\\\\4980 A05-1 Access door\\\\Forms\\\\4980 A05-1 PT02.pgm\" \"C:\\\\Users\\\\aisaacs\\\\Desktop\\\\Projects\\\\ExportDXF\\\\FabWorks.Tests\\\\TestData\\\\sample.pgm\")",
"Bash(dotnet test:*)",
"Bash(del \"C:\\\\Users\\\\aisaacs\\\\Desktop\\\\Projects\\\\ExportDXF\\\\FabWorks.Api\\\\FabWorks.Api.http\")",
"Bash(dotnet ef:*)",
"Bash(git log:*)",
"mcp__RoslynBridge__get_namespace_types",
"Bash(test:*)",
"mcp__RoslynBridge__search_symbol",
"mcp__RoslynBridge__search_code"
],
"deny": [],
"ask": []

View File

@@ -14,7 +14,6 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CoenM.ImageSharp.ImageHash" Version="1.1.5" />
<PackageReference Include="PDFtoImage" Version="4.1.1" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.7" />
<PackageReference Include="System.Configuration.ConfigurationManager" Version="8.0.0" />

View File

@@ -3,7 +3,10 @@ using ExportDXF.Forms;
using ExportDXF.Services;
using System;
using System.Configuration;
using System.Diagnostics;
using System.IO;
using System.Net.Http;
using System.Threading;
using System.Windows.Forms;
namespace ExportDXF
@@ -44,6 +47,8 @@ namespace ExportDXF
var partExporter = new PartExporter();
var drawingExporter = new DrawingExporter();
EnsureApiRunning();
var httpClient = new HttpClient
{
BaseAddress = new Uri(_apiBaseUrl),
@@ -60,5 +65,52 @@ namespace ExportDXF
return new MainForm(solidWorksService, exportService, apiClient);
}
private void EnsureApiRunning()
{
// Check if API is already responding
using (var probe = new HttpClient { Timeout = TimeSpan.FromSeconds(2) })
{
try
{
var response = probe.GetAsync(_apiBaseUrl + "/api/exports?take=1").Result;
if (response.IsSuccessStatusCode)
return; // already running
}
catch { }
}
// Find the API executable relative to this assembly
var exeDir = AppContext.BaseDirectory;
var apiExe = Path.GetFullPath(Path.Combine(exeDir, @"..\..\..\FabWorks.Api\bin\Debug\net8.0\FabWorks.Api.exe"));
if (!File.Exists(apiExe))
return; // can't find it, skip
var startInfo = new ProcessStartInfo
{
FileName = apiExe,
WorkingDirectory = Path.GetDirectoryName(apiExe),
UseShellExecute = false,
CreateNoWindow = true
};
Process.Start(startInfo);
// Wait up to 10 seconds for API to become ready
using (var probe = new HttpClient { Timeout = TimeSpan.FromSeconds(2) })
{
for (int i = 0; i < 20; i++)
{
Thread.Sleep(500);
try
{
var response = probe.GetAsync(_apiBaseUrl + "/api/exports?take=1").Result;
if (response.IsSuccessStatusCode)
return;
}
catch { }
}
}
}
}
}

View File

@@ -237,6 +237,9 @@ namespace ExportDXF.Services
pdfHash,
exportRecord?.Id);
if (exportRecord != null)
await _apiClient.UpdatePdfHashAsync(exportRecord.Id, pdfHash);
if (uploadResult != null)
{
if (uploadResult.WasUnchanged)

View File

@@ -54,7 +54,7 @@ namespace ExportDXF.Services
try
{
var fileName = GetSinglePartFileName(model, context.FilePrefix);
var fileName = GetSinglePartFileName(model, context.Equipment);
var savePath = Path.Combine(saveDirectory, fileName + ".dxf");
// Build result item with metadata
@@ -139,7 +139,7 @@ namespace ExportDXF.Services
EnrichItemWithMetadata(item, model, part);
var fileName = GetItemFileName(item, context.FilePrefix);
var fileName = GetItemFileName(item, context);
var savePath = Path.Combine(saveDirectory, fileName + ".dxf");
var templateDrawing = context.GetOrCreateTemplateDrawing();
@@ -332,23 +332,33 @@ namespace ExportDXF.Services
}
}
private string GetSinglePartFileName(ModelDoc2 model, string prefix)
private string GetSinglePartFileName(ModelDoc2 model, string equipment)
{
var title = model.GetTitle().Replace(".SLDPRT", "");
var config = model.ConfigurationManager.ActiveConfiguration.Name;
var isDefaultConfig = string.Equals(config, "default", StringComparison.OrdinalIgnoreCase);
return isDefaultConfig ? title : $"{title} [{config}]";
var name = isDefaultConfig ? title : $"{title} [{config}]";
return string.IsNullOrWhiteSpace(equipment) ? name : $"{equipment} {name}";
}
private string GetItemFileName(Item item, string prefix)
private string GetItemFileName(Item item, ExportContext context)
{
if (string.IsNullOrWhiteSpace(context.DrawingNo))
{
// No drawing number: preserve part name, prefix with EquipmentNo
var equipment = context.Equipment;
return string.IsNullOrWhiteSpace(equipment)
? item.PartName
: $"{equipment} {item.PartName}";
}
if (string.IsNullOrWhiteSpace(item.ItemNo))
return item.PartName;
prefix = prefix?.Replace("\"", "''") ?? string.Empty;
var prefix = context.FilePrefix?.Replace("\"", "''") ?? string.Empty;
var num = item.ItemNo.PadLeft(2, '0');
// Expected format: {DrawingNo} PT{ItemNo}
// Expected format: {EquipNo} {DrawingNo} PT{ItemNo}
return string.IsNullOrWhiteSpace(prefix)
? $"PT{num}"
: $"{prefix} PT{num}";

View File

@@ -7,11 +7,10 @@ using System.Security.Cryptography;
using System.Text;
using ACadSharp.Entities;
using ACadSharp.IO;
using CoenM.ImageHash;
using CoenM.ImageHash.HashAlgorithms;
using PDFtoImage;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
namespace ExportDXF.Utilities
{
@@ -48,13 +47,12 @@ namespace ExportDXF.Utilities
using (var pngStream = new MemoryStream())
{
Conversion.SavePng(pngStream, pdfStream, page: 0,
options: new RenderOptions(Dpi: 72));
options: new RenderOptions(Dpi: 150));
pngStream.Position = 0;
using (var image = Image.Load<Rgba32>(pngStream))
{
var algorithm = new DifferenceHash();
var hash = algorithm.Hash(image);
var hash = ComputeDifferenceHash(image);
return hash.ToString("x16");
}
}
@@ -78,6 +76,38 @@ namespace ExportDXF.Utilities
}
}
/// <summary>
/// DifferenceHash: resize to 9x8 grayscale, compare adjacent pixels.
/// Produces a 64-bit hash. Implemented directly against ImageSharp 3.x API
/// (CoenM.ImageHash uses the removed GetPixelRowSpan from ImageSharp 2.x).
/// </summary>
private static ulong ComputeDifferenceHash(Image<Rgba32> image)
{
// Resize to 9 wide x 8 tall for 8x8 = 64 bit comparisons
image.Mutate(ctx => ctx.Resize(9, 8));
ulong hash = 0;
int bit = 0;
for (int y = 0; y < 8; y++)
{
for (int x = 0; x < 8; x++)
{
var left = image[x, y];
var right = image[x + 1, y];
var leftGray = 0.299 * left.R + 0.587 * left.G + 0.114 * left.B;
var rightGray = 0.299 * right.R + 0.587 * right.G + 0.114 * right.B;
if (leftGray > rightGray)
hash |= (1UL << bit);
bit++;
}
}
return hash;
}
private static string ComputeGeometricHash(string filePath)
{
using (var reader = new DxfReader(filePath))

View File

@@ -1,4 +1,6 @@
using System.Globalization;
using System.IO.Compression;
using System.Numerics;
using FabWorks.Api.DTOs;
using FabWorks.Api.Services;
using FabWorks.Core.Data;
@@ -215,8 +217,9 @@ namespace FabWorks.Api.Controllers
if (!string.IsNullOrEmpty(record.DrawingNumber) && !string.IsNullOrEmpty(request.PdfContentHash))
{
var drawing = await ResolveDrawingAsync(record.DrawingNumber, record.Title, request.PdfContentHash);
record.DrawingId = drawing.Id;
var (drawing, revision) = await ResolveDrawingAsync(record.DrawingNumber, record.Title, request.PdfContentHash);
record.Drawing = drawing;
record.DrawingRevision = revision;
}
await _db.SaveChangesAsync();
@@ -224,36 +227,42 @@ namespace FabWorks.Api.Controllers
return NoContent();
}
private async Task<Drawing> ResolveDrawingAsync(string drawingNumber, string title, string pdfContentHash)
private async Task<(Drawing drawing, int revision)> ResolveDrawingAsync(string drawingNumber, string title, string pdfContentHash)
{
var drawing = await _db.Drawings
.FirstOrDefaultAsync(d => d.DrawingNumber == drawingNumber);
// Get the highest revision recorded for this drawing across all exports
var lastRevision = await _db.ExportRecords
.Where(r => r.DrawingNumber == drawingNumber && r.DrawingRevision != null)
.OrderByDescending(r => r.DrawingRevision)
.Select(r => r.DrawingRevision)
.FirstOrDefaultAsync() ?? 0;
if (drawing == null)
{
drawing = new Drawing
{
DrawingNumber = drawingNumber,
Title = title,
PdfContentHash = pdfContentHash,
Revision = 1
PdfContentHash = pdfContentHash
};
_db.Drawings.Add(drawing);
}
else if (drawing.PdfContentHash != pdfContentHash)
{
drawing.PdfContentHash = pdfContentHash;
drawing.Revision++;
if (!string.IsNullOrEmpty(title))
drawing.Title = title;
}
// If hash matches, keep same revision (just update title if needed)
else if (!string.IsNullOrEmpty(title))
{
drawing.Title = title;
return (drawing, 1);
}
return drawing;
if (!string.IsNullOrEmpty(title))
drawing.Title = title;
if (ArePerceptualHashesSimilar(drawing.PdfContentHash, pdfContentHash))
{
// Hash unchanged — keep same revision
return (drawing, lastRevision == 0 ? 1 : lastRevision);
}
// Hash changed — bump revision and update stored hash
drawing.PdfContentHash = pdfContentHash;
return (drawing, lastRevision + 1);
}
[HttpGet("previous-cut-template")]
@@ -316,6 +325,37 @@ namespace FabWorks.Api.Controllers
if (dxfItems.Count == 0) return NotFound("No DXF files for this export.");
var zipName = $"{record.DrawingNumber ?? $"Export-{id}"} DXFs.zip";
return BuildDxfZip(dxfItems, zipName);
}
[HttpGet("download-dxfs")]
public async Task<IActionResult> DownloadDxfsByDrawing([FromQuery] string drawingNumber)
{
if (string.IsNullOrEmpty(drawingNumber))
return BadRequest("drawingNumber is required.");
var dxfItems = await _db.BomItems
.Include(b => b.CutTemplate)
.Where(b => b.ExportRecord.DrawingNumber == drawingNumber
&& b.CutTemplate != null
&& b.CutTemplate.ContentHash != null)
.ToListAsync();
if (dxfItems.Count == 0) return NotFound("No DXF files for this drawing.");
// Deduplicate by content hash (keep latest)
dxfItems = dxfItems
.GroupBy(b => b.CutTemplate.ContentHash)
.Select(g => g.Last())
.ToList();
var zipName = $"{drawingNumber} DXFs.zip";
return BuildDxfZip(dxfItems, zipName);
}
private FileResult BuildDxfZip(List<BomItem> dxfItems, string zipName)
{
var ms = new MemoryStream();
using (var zip = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true))
{
@@ -341,16 +381,41 @@ namespace FabWorks.Api.Controllers
var entry = zip.CreateEntry(fileName, CompressionLevel.Fastest);
using var entryStream = entry.Open();
await blobStream.CopyToAsync(entryStream);
blobStream.CopyTo(entryStream);
blobStream.Dispose();
}
}
ms.Position = 0;
var zipName = $"{record.DrawingNumber ?? $"Export-{id}"} DXFs.zip";
return File(ms, "application/zip", zipName);
}
/// <summary>
/// Compares two perceptual hashes using Hamming distance.
/// Perceptual hashes (16 hex chars / 64 bits) are compared with a tolerance
/// of up to 10 differing bits (~84% similarity). SHA256 fallback hashes
/// (64 hex chars) use exact comparison.
/// </summary>
private static bool ArePerceptualHashesSimilar(string hash1, string hash2)
{
if (hash1 == hash2) return true;
if (string.IsNullOrEmpty(hash1) || string.IsNullOrEmpty(hash2)) return false;
// Perceptual hashes are 16 hex chars (64-bit DifferenceHash)
// SHA256 fallback hashes are 64 hex chars — require exact match
if (hash1.Length != 16 || hash2.Length != 16)
return false;
if (ulong.TryParse(hash1, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var h1) &&
ulong.TryParse(hash2, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var h2))
{
var hammingDistance = BitOperations.PopCount(h1 ^ h2);
return hammingDistance <= 10;
}
return false;
}
private static ExportDetailDto MapToDto(ExportRecord r) => new()
{
Id = r.Id,

View File

@@ -88,7 +88,7 @@ namespace FabWorks.Api.Controllers
r.DrawingNumber,
r.PdfContentHash,
r.ExportedAt,
DrawingRevision = r.Drawing != null ? (int?)r.Drawing.Revision : null
r.DrawingRevision
});
if (!string.IsNullOrWhiteSpace(search))

View File

@@ -30,7 +30,7 @@ namespace FabWorks.Api.Controllers
return BadRequest("No file uploaded.");
using var stream = file.OpenReadStream();
var result = await _fileStorage.StoreDxfAsync(stream, equipment, drawingNo, itemNo, contentHash);
var result = await _fileStorage.StoreDxfAsync(stream, equipment, drawingNo, itemNo, contentHash, file.FileName);
return Ok(new FileUploadResponse
{

View File

@@ -16,7 +16,7 @@ namespace FabWorks.Api.Services
public interface IFileStorageService
{
string OutputFolder { get; }
Task<FileUploadResult> StoreDxfAsync(Stream stream, string equipment, string drawingNo, string itemNo, string contentHash);
Task<FileUploadResult> StoreDxfAsync(Stream stream, string equipment, string drawingNo, string itemNo, string contentHash, string originalFileName = null);
Task<FileUploadResult> StorePdfAsync(Stream stream, string equipment, string drawingNo, string contentHash, int? exportRecordId = null);
Stream OpenBlob(string contentHash, string extension);
bool BlobExists(string contentHash, string extension);
@@ -39,9 +39,9 @@ namespace FabWorks.Api.Services
Directory.CreateDirectory(blobRoot);
}
public async Task<FileUploadResult> StoreDxfAsync(Stream stream, string equipment, string drawingNo, string itemNo, string contentHash)
public async Task<FileUploadResult> StoreDxfAsync(Stream stream, string equipment, string drawingNo, string itemNo, string contentHash, string originalFileName = null)
{
var fileName = BuildDxfFileName(drawingNo, equipment, itemNo);
var fileName = BuildDxfFileName(drawingNo, equipment, itemNo, originalFileName);
// Look up previous hash by drawing number + item number
var drawingNumber = BuildDrawingNumber(equipment, drawingNo);
@@ -147,8 +147,16 @@ namespace FabWorks.Api.Services
return drawingNo ?? "";
}
private static string BuildDxfFileName(string drawingNo, string equipment, string itemNo)
private static string BuildDxfFileName(string drawingNo, string equipment, string itemNo, string originalFileName = null)
{
// No drawing number: use the original filename from the client
if (string.IsNullOrEmpty(drawingNo) && !string.IsNullOrEmpty(originalFileName))
{
return originalFileName.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase)
? originalFileName
: originalFileName + ".dxf";
}
var drawingNumber = BuildDrawingNumber(equipment, drawingNo);
var paddedItem = (itemNo ?? "").PadLeft(2, '0');
if (!string.IsNullOrEmpty(drawingNumber) && !string.IsNullOrEmpty(itemNo))

View File

@@ -735,6 +735,85 @@ tbody tr:last-child td { border-bottom: none; }
.equip-group.collapsed .equip-body { display: none; }
.equip-group.collapsed .equip-header { border-radius: 6px; }
/* ─── Modal ─── */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.35);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
animation: fadeIn 0.15s ease;
}
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
.modal-panel {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
width: 90%;
max-width: 640px;
max-height: 80vh;
display: flex;
flex-direction: column;
animation: fadeSlideIn 0.2s ease forwards;
}
.modal-header {
padding: 14px 18px;
border-bottom: 1px solid var(--border-subtle);
font-family: var(--font-display);
font-weight: 600;
font-size: 14px;
letter-spacing: 0.02em;
display: flex;
align-items: center;
gap: 10px;
text-transform: uppercase;
color: var(--text-secondary);
}
.modal-header svg { width: 16px; height: 16px; }
.modal-body {
overflow-y: auto;
flex: 1;
}
.btn-green {
background: var(--green-dim);
color: var(--green);
border-color: rgba(6, 118, 71, 0.25);
}
.btn-green:hover {
background: rgba(6, 118, 71, 0.15);
border-color: rgba(6, 118, 71, 0.4);
color: var(--green);
}
/* ─── Toast ─── */
.toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
background: var(--text);
color: #fff;
padding: 8px 20px;
border-radius: 4px;
font-family: var(--font-mono);
font-size: 13px;
z-index: 300;
animation: fadeSlideIn 0.2s ease, fadeOut 0.3s ease 2s forwards;
}
@keyframes fadeOut { to { opacity: 0; } }
/* ─── Responsive ─── */
@media (max-width: 768px) {
.sidebar { display: none; }

View File

@@ -46,11 +46,11 @@
<div class="page-content" id="page-content"></div>
</div>
<script src="js/icons.js?v=2"></script>
<script src="js/helpers.js?v=2"></script>
<script src="js/components.js?v=2"></script>
<script src="js/pages.js?v=2"></script>
<script src="js/router.js?v=2"></script>
<script src="js/icons.js?v=3"></script>
<script src="js/helpers.js?v=3"></script>
<script src="js/components.js?v=3"></script>
<script src="js/pages.js?v=3"></script>
<script src="js/router.js?v=3"></script>
<script>router.init();</script>
</body>
</html>

View File

@@ -58,3 +58,98 @@ function toggleBomRow(id) {
row.style.display = visible ? 'none' : '';
if (icon) icon.classList.toggle('open', !visible);
}
/* ─── Cut List Modal ─── */
function showCutListModal(bomItems) {
const cutItems = bomItems.filter(b => b.cutTemplate);
if (cutItems.length === 0) {
showToast('No cut templates found');
return;
}
const rows = cutItems.map(b => {
const ct = b.cutTemplate;
const name = ct.cutTemplateName || ct.dxfFilePath?.split(/[/\\]/).pop()?.replace(/\.dxf$/i, '') || b.partName || '';
const qty = b.qty ?? '';
return { name, qty };
});
const tableRows = rows.map((r, i) => `
<tr style="animation: fadeSlideIn 0.15s ease ${0.02 * i}s forwards; opacity: 0">
<td style="font-family:var(--font-mono);font-weight:600">${esc(r.name)}</td>
<td style="font-family:var(--font-mono);text-align:center">${r.qty}</td>
</tr>`).join('');
// Remove existing modal if any
const existing = document.getElementById('cut-list-modal');
if (existing) existing.remove();
const modal = document.createElement('div');
modal.id = 'cut-list-modal';
modal.className = 'modal-overlay';
modal.innerHTML = `
<div class="modal-panel">
<div class="modal-header">
<span>${icons.laser} Cut List</span>
<span class="badge badge-count">${cutItems.length} templates</span>
<span style="margin-left:auto;display:flex;gap:6px">
<button class="btn btn-cyan btn-sm" onclick="copyCutList()" id="copy-cut-list-btn">${icons.clipboard} Copy</button>
<button class="btn btn-sm" onclick="closeCutListModal()">${icons.close}</button>
</span>
</div>
<div class="modal-body">
<table>
<thead><tr>
<th>Name</th>
<th style="width:60px;text-align:center">Qty</th>
</tr></thead>
<tbody>${tableRows}</tbody>
</table>
</div>
</div>`;
document.body.appendChild(modal);
// Store data for copy
modal._cutData = rows;
// Close on backdrop click
modal.addEventListener('click', e => { if (e.target === modal) closeCutListModal(); });
// Close on Escape
modal._keyHandler = e => { if (e.key === 'Escape') closeCutListModal(); };
document.addEventListener('keydown', modal._keyHandler);
}
function closeCutListModal() {
const modal = document.getElementById('cut-list-modal');
if (!modal) return;
document.removeEventListener('keydown', modal._keyHandler);
modal.remove();
}
function copyCutList() {
const modal = document.getElementById('cut-list-modal');
if (!modal || !modal._cutData) return;
const text = modal._cutData.map(r => `${r.name}\t${r.qty}`).join('\n');
navigator.clipboard.writeText(text).then(() => {
const btn = document.getElementById('copy-cut-list-btn');
if (btn) {
btn.innerHTML = `${icons.check} Copied!`;
btn.classList.remove('btn-cyan');
btn.classList.add('btn-green');
setTimeout(() => {
btn.innerHTML = `${icons.clipboard} Copy`;
btn.classList.remove('btn-green');
btn.classList.add('btn-cyan');
}, 2000);
}
});
}
function showToast(msg) {
const t = document.createElement('div');
t.className = 'toast';
t.textContent = msg;
document.body.appendChild(t);
setTimeout(() => t.remove(), 2500);
}

View File

@@ -10,6 +10,9 @@ const icons = {
laser: `<svg viewBox="0 0 16 16" fill="none" stroke="var(--cyan)" stroke-width="1.2"><circle cx="8" cy="8" r="2"/><path d="M8 2v3M8 11v3M2 8h3M11 8h3" opacity="0.5"/></svg>`,
bend: `<svg viewBox="0 0 16 16" fill="none" stroke="var(--amber)" stroke-width="1.2"><path d="M3 13V7a4 4 0 0 1 4-4h6"/><polyline points="10 6 13 3 10 0" transform="translate(0,2)"/></svg>`,
trash: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>`,
clipboard: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="2" width="6" height="4" rx="1"/><path d="M9 2H7a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2h-2"/><line x1="9" y1="12" x2="15" y2="12"/><line x1="9" y1="16" x2="15" y2="16"/></svg>`,
check: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg>`,
close: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`,
};
function fileIcon(name) {

View File

@@ -213,6 +213,9 @@ const pages = {
allBom = [...bomByItem.values()];
}
// Store for cut list modal
window._currentBom = allBom;
const bomRows = allBom.map((b, i) => {
const hasDetails = b.cutTemplate || b.formProgram;
const toggleId = `dbom-${b.id}`;
@@ -265,8 +268,12 @@ const pages = {
${bomHeader}
<span class="badge badge-count">${allBom.length} items</span>
<span style="margin-left:auto;display:flex;gap:6px">
${dxfCount > 0 ? `<button class="btn btn-sm" onclick="showCutListModal(window._currentBom)">${icons.clipboard} Cut List</button>` : ''}
${pdfHash ? `<a class="btn btn-amber btn-sm" href="/api/filebrowser/download?hash=${encodeURIComponent(pdfHash)}&ext=pdf&name=${pdfName}">${icons.download} PDF</a>` : ''}
${dxfCount > 0 ? `<a class="btn btn-cyan btn-sm" href="/api/exports/${activeExport.id}/download-dxfs">${icons.download} All DXFs</a>` : ''}
${dxfCount > 0 ? (singleExport
? `<a class="btn btn-cyan btn-sm" href="/api/exports/${activeExport.id}/download-dxfs">${icons.download} All DXFs</a>`
: `<a class="btn btn-cyan btn-sm" href="/api/exports/download-dxfs?drawingNumber=${encodeURIComponent(drawingNumber)}">${icons.download} All DXFs</a>`
) : ''}
</span>
</div>
${allBom.length ? `

View File

@@ -0,0 +1,325 @@
// <auto-generated />
using System;
using FabWorks.Core.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace FabWorks.Core.Migrations
{
[DbContext(typeof(FabWorksDbContext))]
[Migration("20260220171747_MoveRevisionFromDrawingToExportRecord")]
partial class MoveRevisionFromDrawingToExportRecord
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.11")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("FabWorks.Core.Models.BomItem", b =>
{
b.Property<int>("ID")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("ID"));
b.Property<string>("ConfigurationName")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<int>("ExportRecordId")
.HasColumnType("int");
b.Property<string>("ItemNo")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("Material")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("PartName")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("PartNo")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<int?>("Qty")
.HasColumnType("int");
b.Property<int>("SortOrder")
.HasColumnType("int");
b.Property<int?>("TotalQty")
.HasColumnType("int");
b.HasKey("ID");
b.HasIndex("ExportRecordId");
b.ToTable("BomItems");
});
modelBuilder.Entity("FabWorks.Core.Models.CutTemplate", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("BomItemId")
.HasColumnType("int");
b.Property<string>("ContentHash")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("CutTemplateName")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<double?>("DefaultBendRadius")
.HasColumnType("float");
b.Property<string>("DxfFilePath")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<double?>("KFactor")
.HasColumnType("float");
b.Property<int>("Revision")
.HasColumnType("int");
b.Property<double?>("Thickness")
.HasColumnType("float");
b.HasKey("Id");
b.HasIndex("BomItemId")
.IsUnique();
b.ToTable("CutTemplates");
});
modelBuilder.Entity("FabWorks.Core.Models.Drawing", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("DrawingNumber")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("PdfContentHash")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("Title")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.HasKey("Id");
b.HasIndex("DrawingNumber")
.IsUnique()
.HasFilter("[DrawingNumber] IS NOT NULL");
b.ToTable("Drawings");
});
modelBuilder.Entity("FabWorks.Core.Models.ExportRecord", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int?>("DrawingId")
.HasColumnType("int");
b.Property<string>("DrawingNo")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("DrawingNumber")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<int?>("DrawingRevision")
.HasColumnType("int");
b.Property<string>("EquipmentNo")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<DateTime>("ExportedAt")
.HasColumnType("datetime2");
b.Property<string>("ExportedBy")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("OutputFolder")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<string>("PdfContentHash")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("SourceFilePath")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<string>("Title")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.HasKey("Id");
b.HasIndex("DrawingId");
b.ToTable("ExportRecords");
});
modelBuilder.Entity("FabWorks.Core.Models.FormProgram", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("BendCount")
.HasColumnType("int");
b.Property<int>("BomItemId")
.HasColumnType("int");
b.Property<string>("ContentHash")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<double?>("KFactor")
.HasColumnType("float");
b.Property<string>("LowerToolNames")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<string>("MaterialType")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("ProgramFilePath")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<string>("ProgramName")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("SetupNotes")
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)");
b.Property<double?>("Thickness")
.HasColumnType("float");
b.Property<string>("UpperToolNames")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.HasKey("Id");
b.HasIndex("BomItemId")
.IsUnique();
b.ToTable("FormPrograms");
});
modelBuilder.Entity("FabWorks.Core.Models.BomItem", b =>
{
b.HasOne("FabWorks.Core.Models.ExportRecord", "ExportRecord")
.WithMany("BomItems")
.HasForeignKey("ExportRecordId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("ExportRecord");
});
modelBuilder.Entity("FabWorks.Core.Models.CutTemplate", b =>
{
b.HasOne("FabWorks.Core.Models.BomItem", "BomItem")
.WithOne("CutTemplate")
.HasForeignKey("FabWorks.Core.Models.CutTemplate", "BomItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("BomItem");
});
modelBuilder.Entity("FabWorks.Core.Models.ExportRecord", b =>
{
b.HasOne("FabWorks.Core.Models.Drawing", "Drawing")
.WithMany("ExportRecords")
.HasForeignKey("DrawingId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Drawing");
});
modelBuilder.Entity("FabWorks.Core.Models.FormProgram", b =>
{
b.HasOne("FabWorks.Core.Models.BomItem", "BomItem")
.WithOne("FormProgram")
.HasForeignKey("FabWorks.Core.Models.FormProgram", "BomItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("BomItem");
});
modelBuilder.Entity("FabWorks.Core.Models.BomItem", b =>
{
b.Navigation("CutTemplate");
b.Navigation("FormProgram");
});
modelBuilder.Entity("FabWorks.Core.Models.Drawing", b =>
{
b.Navigation("ExportRecords");
});
modelBuilder.Entity("FabWorks.Core.Models.ExportRecord", b =>
{
b.Navigation("BomItems");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,39 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace FabWorks.Core.Migrations
{
/// <inheritdoc />
public partial class MoveRevisionFromDrawingToExportRecord : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Revision",
table: "Drawings");
migrationBuilder.AddColumn<int>(
name: "DrawingRevision",
table: "ExportRecords",
type: "int",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "DrawingRevision",
table: "ExportRecords");
migrationBuilder.AddColumn<int>(
name: "Revision",
table: "Drawings",
type: "int",
nullable: false,
defaultValue: 0);
}
}
}

View File

@@ -132,9 +132,6 @@ namespace FabWorks.Core.Migrations
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<int>("Revision")
.HasColumnType("int");
b.Property<string>("Title")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
@@ -167,6 +164,9 @@ namespace FabWorks.Core.Migrations
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<int?>("DrawingRevision")
.HasColumnType("int");
b.Property<string>("EquipmentNo")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");

View File

@@ -8,7 +8,6 @@ namespace FabWorks.Core.Models
public string DrawingNumber { get; set; }
public string Title { get; set; }
public string PdfContentHash { get; set; }
public int Revision { get; set; } = 1;
public virtual ICollection<ExportRecord> ExportRecords { get; set; } = new List<ExportRecord>();
}

View File

@@ -17,6 +17,7 @@ namespace FabWorks.Core.Models
public string PdfContentHash { get; set; }
public int? DrawingId { get; set; }
public int? DrawingRevision { get; set; }
public virtual Drawing Drawing { get; set; }
public virtual ICollection<BomItem> BomItems { get; set; } = new List<BomItem>();