Compare commits

...

11 Commits

Author SHA1 Message Date
aj f6cd91f1b5 docs: add design plans for API and auto-fill features
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 08:54:21 -05:00
aj 3554bb6110 feat: show revision column in file browser
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 08:54:13 -05:00
aj 4707e96359 feat: resolve drawing revisions on PDF upload
When a PDF hash is set on an export record, resolve or create the
associated Drawing and bump its revision if the content hash changed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 08:54:07 -05:00
aj c5bd7fb4c8 feat: add Drawing entity with revision tracking
Introduce a Drawing table that tracks unique drawing numbers with their
current PDF content hash and revision counter. ExportRecord gains a
DrawingId FK. Includes a data migration to seed Drawings from existing
export records.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 08:54:01 -05:00
aj 13c61a82a4 fix: log etch line failures instead of silently swallowing
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 08:53:51 -05:00
aj 444a077cbc fix: resize form controls to match window layout
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 08:53:46 -05:00
aj c4920f933d chore: remove netDxf project from solution
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 08:53:39 -05:00
aj b472729fda feat: use perceptual hash for PDF change detection
Render PDF page 1 to an image and compute a DifferenceHash instead of
SHA256 on raw file bytes. This ignores metadata/timestamp changes that
SolidWorks varies between exports, preventing false revision bumps on
Drawing entities.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 08:52:06 -05:00
aj 5d2948d563 feat: replace text-based DXF hash with geometric content hash
SolidWorks re-exports produce files with identical geometry but different
entity ordering, handle assignments, style names, and floating-point
epsilon values. This caused hash mismatches and unnecessary API updates.

Uses ACadSharp to parse DXF entities and build canonical, sorted
signatures (LINE, ARC, CIRCLE, MTEXT) with coordinates rounded to 4
decimal places. Falls back to raw file hash if parsing fails.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 07:40:51 -05:00
aj 71c65e0bf5 feat: auto-populate title from part description when opening documents
Adds a Description property to DrawingInfo that extracts the
descriptive text after the equipment/drawing number, excluding the
file extension. MainForm now sets the title box from this description
when no title was already set by the API history lookup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 05:53:08 -05:00
aj 53aa23f762 refactor: consolidate export detail into drawing detail page
Remove the duplicate export detail page and route exports list
directly to drawing detail. When navigating from exports, the
specific export's BOM items are shown via eid param; from drawings,
items are deduplicated to the latest revision. Add Rev column,
PDF download, and All DXFs download to drawing detail.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 17:26:43 -05:00
22 changed files with 2446 additions and 210 deletions
+2 -16
View File
@@ -1,14 +1,12 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 16
VisualStudioVersion = 16.0.29123.88
# Visual Studio Version 18
VisualStudioVersion = 18.3.11512.155 d18.3
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExportDXF", "ExportDXF\ExportDXF.csproj", "{05F21D73-FD31-4E77-8D9B-41C86D4D8305}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EtchBendLines", "EtchBendLines\EtchBendLines\EtchBendLines.csproj", "{229C2FB9-6AD6-4A5D-B83A-D1146573D6F9}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "netDxf", "EtchBendLines\netDxf\netDxf\netDxf.csproj", "{785380E0-CEB9-4C34-82E5-60D0E33E848E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FabWorks.Core", "FabWorks.Core\FabWorks.Core.csproj", "{24547EE4-2EAA-4A6C-AD94-1117C038D8CD}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FabWorks.Tests", "FabWorks.Tests\FabWorks.Tests.csproj", "{6DD89774-D86B-47E9-B982-2794BD95616A}"
@@ -49,18 +47,6 @@ Global
{229C2FB9-6AD6-4A5D-B83A-D1146573D6F9}.Release|x64.Build.0 = Release|Any CPU
{229C2FB9-6AD6-4A5D-B83A-D1146573D6F9}.Release|x86.ActiveCfg = Release|Any CPU
{229C2FB9-6AD6-4A5D-B83A-D1146573D6F9}.Release|x86.Build.0 = Release|Any CPU
{785380E0-CEB9-4C34-82E5-60D0E33E848E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{785380E0-CEB9-4C34-82E5-60D0E33E848E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{785380E0-CEB9-4C34-82E5-60D0E33E848E}.Debug|x64.ActiveCfg = Debug|Any CPU
{785380E0-CEB9-4C34-82E5-60D0E33E848E}.Debug|x64.Build.0 = Debug|Any CPU
{785380E0-CEB9-4C34-82E5-60D0E33E848E}.Debug|x86.ActiveCfg = Debug|Any CPU
{785380E0-CEB9-4C34-82E5-60D0E33E848E}.Debug|x86.Build.0 = Debug|Any CPU
{785380E0-CEB9-4C34-82E5-60D0E33E848E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{785380E0-CEB9-4C34-82E5-60D0E33E848E}.Release|Any CPU.Build.0 = Release|Any CPU
{785380E0-CEB9-4C34-82E5-60D0E33E848E}.Release|x64.ActiveCfg = Release|Any CPU
{785380E0-CEB9-4C34-82E5-60D0E33E848E}.Release|x64.Build.0 = Release|Any CPU
{785380E0-CEB9-4C34-82E5-60D0E33E848E}.Release|x86.ActiveCfg = Release|Any CPU
{785380E0-CEB9-4C34-82E5-60D0E33E848E}.Release|x86.Build.0 = Release|Any CPU
{24547EE4-2EAA-4A6C-AD94-1117C038D8CD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{24547EE4-2EAA-4A6C-AD94-1117C038D8CD}.Debug|Any CPU.Build.0 = Debug|Any CPU
{24547EE4-2EAA-4A6C-AD94-1117C038D8CD}.Debug|x64.ActiveCfg = Debug|Any CPU
+30
View File
@@ -1,3 +1,4 @@
using System.IO;
using System.Text.RegularExpressions;
namespace ExportDXF
@@ -13,6 +14,35 @@ namespace ExportDXF
public string Source { get; set; }
/// <summary>
/// The descriptive text after the equipment/drawing number (e.g. "Prox switch bracket for drive").
/// </summary>
public string Description
{
get
{
if (string.IsNullOrEmpty(Source) || string.IsNullOrEmpty(EquipmentNo))
return null;
// Strip equipment number (and optional drawing number) from the source to get the description
var prefix = string.IsNullOrEmpty(DrawingNo)
? EquipmentNo
: EquipmentNo + " " + DrawingNo;
var desc = Source;
if (desc.StartsWith(prefix, System.StringComparison.OrdinalIgnoreCase))
desc = desc.Substring(prefix.Length);
// Remove file extension (e.g. ".SLDPRT")
var ext = Path.GetExtension(desc);
if (!string.IsNullOrEmpty(ext))
desc = desc.Substring(0, desc.Length - ext.Length);
desc = desc.Trim();
return string.IsNullOrEmpty(desc) ? null : desc;
}
}
public override string ToString()
{
if (string.IsNullOrEmpty(DrawingNo))
+3
View File
@@ -14,6 +14,9 @@
</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" />
</ItemGroup>
+7 -7
View File
@@ -114,16 +114,16 @@ namespace ExportDXF.Forms
logEventsDataGrid.GridColor = System.Drawing.Color.WhiteSmoke;
logEventsDataGrid.Location = new System.Drawing.Point(6, 6);
logEventsDataGrid.Name = "logEventsDataGrid";
logEventsDataGrid.Size = new System.Drawing.Size(890, 440);
logEventsDataGrid.Size = new System.Drawing.Size(890, 444);
logEventsDataGrid.TabIndex = 0;
//
// bomTab
//
bomTab.Controls.Add(bomDataGrid);
bomTab.Location = new System.Drawing.Point(4, 28);
bomTab.Location = new System.Drawing.Point(4, 30);
bomTab.Name = "bomTab";
bomTab.Padding = new System.Windows.Forms.Padding(3);
bomTab.Size = new System.Drawing.Size(902, 409);
bomTab.Size = new System.Drawing.Size(902, 458);
bomTab.TabIndex = 1;
bomTab.Text = "Bill Of Materials";
bomTab.UseVisualStyleBackColor = true;
@@ -135,16 +135,16 @@ namespace ExportDXF.Forms
bomDataGrid.GridColor = System.Drawing.Color.WhiteSmoke;
bomDataGrid.Location = new System.Drawing.Point(6, 6);
bomDataGrid.Name = "bomDataGrid";
bomDataGrid.Size = new System.Drawing.Size(1281, 644);
bomDataGrid.Size = new System.Drawing.Size(890, 444);
bomDataGrid.TabIndex = 1;
//
// cutTemplatesTab
//
cutTemplatesTab.Controls.Add(cutTemplatesDataGrid);
cutTemplatesTab.Location = new System.Drawing.Point(4, 28);
cutTemplatesTab.Location = new System.Drawing.Point(4, 30);
cutTemplatesTab.Name = "cutTemplatesTab";
cutTemplatesTab.Padding = new System.Windows.Forms.Padding(3);
cutTemplatesTab.Size = new System.Drawing.Size(902, 409);
cutTemplatesTab.Size = new System.Drawing.Size(902, 458);
cutTemplatesTab.TabIndex = 2;
cutTemplatesTab.Text = "Cut Templates";
cutTemplatesTab.UseVisualStyleBackColor = true;
@@ -156,7 +156,7 @@ namespace ExportDXF.Forms
cutTemplatesDataGrid.GridColor = System.Drawing.Color.WhiteSmoke;
cutTemplatesDataGrid.Location = new System.Drawing.Point(6, 6);
cutTemplatesDataGrid.Name = "cutTemplatesDataGrid";
cutTemplatesDataGrid.Size = new System.Drawing.Size(1281, 644);
cutTemplatesDataGrid.Size = new System.Drawing.Size(890, 447);
cutTemplatesDataGrid.TabIndex = 2;
//
// equipmentBox
+3
View File
@@ -487,6 +487,9 @@ namespace ExportDXF.Forms
equipmentBox.Text = drawingInfo.EquipmentNo;
}
if (string.IsNullOrEmpty(titleBox.Text) && !string.IsNullOrEmpty(drawingInfo.Description))
titleBox.Text = drawingInfo.Description;
// Load drawings for the selected equipment, then set drawing number
await UpdateDrawingDropdownAsync();
+87 -14
View File
@@ -207,7 +207,6 @@ namespace ExportDXF.Services
private async Task ExportDrawingAsync(ExportContext context, string drawingNumber, string tempDir)
{
LogProgress(context, "Active document is a Drawing");
LogProgress(context, "Finding BOM tables...");
var drawing = context.ActiveDocument.NativeDocument as DrawingDoc;
if (drawing == null)
@@ -216,16 +215,6 @@ namespace ExportDXF.Services
return;
}
var items = _bomExtractor.ExtractFromDrawing(drawing, context.ProgressCallback);
if (items == null || items.Count == 0)
{
LogProgress(context, "Error: Bill of materials not found.", LogLevel.Error);
return;
}
LogProgress(context, $"Found {items.Count} component(s)");
// Export drawing to PDF in temp dir
_drawingExporter.ExportToPdf(drawing, tempDir, context);
@@ -239,7 +228,7 @@ namespace ExportDXF.Services
if (pdfs.Length > 0)
{
var pdfTempPath = pdfs[0];
var pdfHash = ContentHasher.ComputeFileHash(pdfTempPath);
var pdfHash = ContentHasher.ComputePdfContentHash(pdfTempPath);
var uploadResult = await _apiClient.UploadPdfAsync(
pdfTempPath,
@@ -264,8 +253,74 @@ namespace ExportDXF.Services
LogProgress(context, $"PDF upload error: {ex.Message}", LogLevel.Error);
}
// Export parts to DXF and save BOM items
await ExportItemsAsync(items, tempDir, context, exportRecord?.Id);
// Extract BOM items from drawing tables
LogProgress(context, "Finding BOM tables...");
var items = _bomExtractor.ExtractFromDrawing(drawing, context.ProgressCallback);
if (items != null && items.Count > 0)
{
LogProgress(context, $"Found {items.Count} component(s)");
await ExportItemsAsync(items, tempDir, context, exportRecord?.Id);
}
else
{
// No BOM table — fall back to exporting the part referenced by the drawing views
LogProgress(context, "No BOM table found. Checking drawing views for referenced part...");
var (part, configuration) = GetReferencedPartFromViews(drawing);
if (part == null)
{
LogProgress(context, "No referenced part found in drawing views.", LogLevel.Warning);
return;
}
LogProgress(context, $"Found referenced part, exporting as single part...");
var item = _partExporter.ExportSinglePart(part, tempDir, context);
if (item != null)
{
if (!string.IsNullOrEmpty(configuration))
item.Configuration = configuration;
var existingItemNo = await FindExistingItemNoAsync(exportRecord?.Id, item.PartName, item.Configuration);
item.ItemNo = existingItemNo ?? await GetNextItemNumberAsync(drawingNumber);
var bomItem = new BomItem
{
ExportRecordId = exportRecord?.Id ?? 0,
ItemNo = item.ItemNo,
PartNo = item.FileName ?? item.PartName ?? "",
SortOrder = 0,
Qty = item.Quantity,
TotalQty = item.Quantity,
Description = item.Description ?? "",
PartName = item.PartName ?? "",
ConfigurationName = item.Configuration ?? "",
Material = item.Material ?? ""
};
if (!string.IsNullOrEmpty(item.LocalTempPath))
{
var uploadResult = await UploadDxfAsync(item, context);
if (uploadResult != null)
{
bomItem.CutTemplate = new CutTemplate
{
DxfFilePath = uploadResult.StoredFilePath,
ContentHash = item.ContentHash,
Thickness = item.Thickness > 0 ? item.Thickness : null,
KFactor = item.KFactor > 0 ? item.KFactor : null,
DefaultBendRadius = item.BendRadius > 0 ? item.BendRadius : null
};
}
}
context.BomItemCallback?.Invoke(bomItem);
if (exportRecord != null)
await SaveBomItemAsync(exportRecord.Id, bomItem, context);
}
}
}
#endregion
@@ -600,6 +655,24 @@ namespace ExportDXF.Services
throw new ArgumentException("ProgressCallback cannot be null.", nameof(context));
}
private (PartDoc part, string configuration) GetReferencedPartFromViews(DrawingDoc drawing)
{
var view = (IView)drawing.GetFirstView();
// First view is the sheet itself — skip it
view = (IView)view.GetNextView();
while (view != null)
{
var doc = view.ReferencedDocument;
if (doc is PartDoc part)
return (part, view.ReferencedConfiguration);
view = (IView)view.GetNextView();
}
return (null, null);
}
private void LogProgress(ExportContext context, string message, LogLevel level = LogLevel.Info, string file = null)
{
context.ProgressCallback?.Invoke(message, level, file);
+4 -4
View File
@@ -234,7 +234,7 @@ namespace ExportDXF.Services
var drawingModel = templateDrawing as ModelDoc2;
drawingModel.SaveAs(savePath);
AddEtchLines(savePath);
AddEtchLines(savePath, context);
context.ProgressCallback?.Invoke($"Saved to \"{savePath}\"", LogLevel.Info, partTitle);
@@ -301,7 +301,7 @@ namespace ExportDXF.Services
drawing.DeleteSelection(false);
}
private void AddEtchLines(string dxfPath)
private void AddEtchLines(string dxfPath, ExportContext context)
{
try
{
@@ -309,9 +309,9 @@ namespace ExportDXF.Services
etcher.AddEtchLines(dxfPath);
FixDegreeSymbol(dxfPath);
}
catch (Exception)
catch (Exception ex)
{
// Silently fail if etch lines can't be added
context.ProgressCallback?.Invoke($"Etch lines failed: {ex.Message}", LogLevel.Warning, Path.GetFileName(dxfPath));
}
}
+127 -73
View File
@@ -1,26 +1,67 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
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;
namespace ExportDXF.Utilities
{
public static class ContentHasher
{
/// <summary>
/// Computes a SHA256 hash of DXF file content, skipping the HEADER section
/// which contains timestamps that change on every save.
/// Computes a SHA256 hash of DXF geometry, ignoring entity ordering,
/// handle assignments, style names, and floating-point epsilon differences
/// that SolidWorks changes between re-exports of identical geometry.
/// Falls back to a raw file hash if ACadSharp parsing fails.
/// </summary>
public static string ComputeDxfContentHash(string filePath)
{
var text = File.ReadAllText(filePath);
var contentStart = FindEndOfHeader(text);
var content = contentStart >= 0 ? text.Substring(contentStart) : text;
using (var sha = SHA256.Create())
try
{
var bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(content));
return BitConverter.ToString(bytes).Replace("-", "").ToLowerInvariant();
return ComputeGeometricHash(filePath);
}
catch
{
return ComputeFileHash(filePath);
}
}
/// <summary>
/// Computes a perceptual hash of a PDF by rendering page 1 to an image,
/// so only visual changes affect the hash (metadata/timestamp changes are ignored).
/// Falls back to a raw file hash if rendering fails.
/// </summary>
public static string ComputePdfContentHash(string filePath)
{
try
{
using (var pdfStream = File.OpenRead(filePath))
using (var pngStream = new MemoryStream())
{
Conversion.SavePng(pngStream, pdfStream, page: 0,
options: new RenderOptions(Dpi: 72));
pngStream.Position = 0;
using (var image = Image.Load<Rgba32>(pngStream))
{
var algorithm = new DifferenceHash();
var hash = algorithm.Hash(image);
return hash.ToString("x16");
}
}
}
catch
{
return ComputeFileHash(filePath);
}
}
@@ -37,82 +78,95 @@ namespace ExportDXF.Utilities
}
}
/// <summary>
/// Finds the position immediately after the HEADER section's ENDSEC marker.
/// DXF HEADER format:
/// 0\nSECTION\n2\nHEADER\n...variables...\n0\nENDSEC\n
/// Returns -1 if no HEADER section is found.
/// </summary>
private static int FindEndOfHeader(string text)
private static string ComputeGeometricHash(string filePath)
{
// Find the HEADER section start
var headerIndex = FindGroupCode(text, 0, "2", "HEADER");
if (headerIndex < 0)
return -1;
// Advance past the HEADER value line so pair scanning stays aligned
var headerLineEnd = text.IndexOf('\n', headerIndex);
if (headerLineEnd < 0)
return -1;
// Find the ENDSEC that closes the HEADER section
var pos = headerLineEnd + 1;
while (pos < text.Length)
using (var reader = new DxfReader(filePath))
{
var endsecIndex = FindGroupCode(text, pos, "0", "ENDSEC");
if (endsecIndex < 0)
return -1;
var doc = reader.Read();
var signatures = new List<string>();
// Move past the ENDSEC line
var lineEnd = text.IndexOf('\n', endsecIndex);
return lineEnd >= 0 ? lineEnd + 1 : text.Length;
foreach (var entity in doc.Entities)
{
signatures.Add(GetEntitySignature(entity));
}
signatures.Sort(StringComparer.Ordinal);
var combined = string.Join("\n", signatures);
using (var sha = SHA256.Create())
{
var bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(combined));
return BitConverter.ToString(bytes).Replace("-", "").ToLowerInvariant();
}
}
return -1;
}
/// <summary>
/// Finds a DXF group code pair (code line followed by value line) starting from the given position.
/// Returns the position of the value line, or -1 if not found.
/// </summary>
private static int FindGroupCode(string text, int startIndex, string groupCode, string value)
private static string GetEntitySignature(Entity entity)
{
var pos = startIndex;
while (pos < text.Length)
var layer = entity.Layer?.Name ?? "";
switch (entity)
{
// Skip whitespace/newlines to find the group code
while (pos < text.Length && (text[pos] == '\r' || text[pos] == '\n' || text[pos] == ' '))
pos++;
case Line line:
return GetLineSignature(line, layer);
case Arc arc:
return GetArcSignature(arc, layer);
case Circle circle:
return GetCircleSignature(circle, layer);
case MText mtext:
return GetMTextSignature(mtext, layer);
default:
return $"{entity.GetType().Name}|{layer}";
}
}
if (pos >= text.Length)
break;
private static string GetLineSignature(Line line, string layer)
{
var p1 = FormatPoint(line.StartPoint.X, line.StartPoint.Y);
var p2 = FormatPoint(line.EndPoint.X, line.EndPoint.Y);
// Read the group code line
var codeLineEnd = text.IndexOf('\n', pos);
if (codeLineEnd < 0)
break;
var codeLine = text.Substring(pos, codeLineEnd - pos).Trim();
// Move to the value line
var valueStart = codeLineEnd + 1;
if (valueStart >= text.Length)
break;
var valueLineEnd = text.IndexOf('\n', valueStart);
if (valueLineEnd < 0)
valueLineEnd = text.Length;
var valueLine = text.Substring(valueStart, valueLineEnd - valueStart).Trim();
if (codeLine == groupCode && string.Equals(valueLine, value, StringComparison.OrdinalIgnoreCase))
return valueStart;
// Move to the next pair
pos = valueLineEnd + 1;
// Normalize endpoint order so direction doesn't affect the hash
if (string.Compare(p1, p2, StringComparison.Ordinal) > 0)
{
var tmp = p1;
p1 = p2;
p2 = tmp;
}
return -1;
return $"LINE|{layer}|{p1}|{p2}";
}
private static string GetArcSignature(Arc arc, string layer)
{
var center = FormatPoint(arc.Center.X, arc.Center.Y);
var r = R(arc.Radius);
var sa = R(arc.StartAngle);
var ea = R(arc.EndAngle);
return $"ARC|{layer}|{center}|{r}|{sa}|{ea}";
}
private static string GetCircleSignature(Circle circle, string layer)
{
var center = FormatPoint(circle.Center.X, circle.Center.Y);
var r = R(circle.Radius);
return $"CIRCLE|{layer}|{center}|{r}";
}
private static string GetMTextSignature(MText mtext, string layer)
{
var point = FormatPoint(mtext.InsertPoint.X, mtext.InsertPoint.Y);
var text = mtext.Value ?? "";
return $"MTEXT|{layer}|{point}|{text}";
}
private static string R(double value)
{
return Math.Round(value, 4).ToString(CultureInfo.InvariantCulture);
}
private static string FormatPoint(double x, double y)
{
return $"{R(x)},{R(y)}";
}
}
}
@@ -212,11 +212,50 @@ namespace FabWorks.Api.Controllers
if (record == null) return NotFound();
record.PdfContentHash = request.PdfContentHash;
if (!string.IsNullOrEmpty(record.DrawingNumber) && !string.IsNullOrEmpty(request.PdfContentHash))
{
var drawing = await ResolveDrawingAsync(record.DrawingNumber, record.Title, request.PdfContentHash);
record.DrawingId = drawing.Id;
}
await _db.SaveChangesAsync();
return NoContent();
}
private async Task<Drawing> ResolveDrawingAsync(string drawingNumber, string title, string pdfContentHash)
{
var drawing = await _db.Drawings
.FirstOrDefaultAsync(d => d.DrawingNumber == drawingNumber);
if (drawing == null)
{
drawing = new Drawing
{
DrawingNumber = drawingNumber,
Title = title,
PdfContentHash = pdfContentHash,
Revision = 1
};
_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;
}
[HttpGet("previous-cut-template")]
public async Task<ActionResult<CutTemplateDto>> GetPreviousCutTemplate(
[FromQuery] string drawingNumber,
@@ -38,6 +38,7 @@ namespace FabWorks.Api.Controllers
c.DxfFilePath,
c.ContentHash,
c.Thickness,
c.Revision,
DrawingNumber = c.BomItem.ExportRecord.DrawingNumber,
CreatedAt = c.BomItem.ExportRecord.ExportedAt
});
@@ -70,6 +71,7 @@ namespace FabWorks.Api.Controllers
FileType = "dxf",
DrawingNumber = c.DrawingNumber,
Thickness = c.Thickness,
Revision = c.Revision,
CreatedAt = c.CreatedAt
});
}
@@ -85,7 +87,8 @@ namespace FabWorks.Api.Controllers
r.Id,
r.DrawingNumber,
r.PdfContentHash,
r.ExportedAt
r.ExportedAt,
DrawingRevision = r.Drawing != null ? (int?)r.Drawing.Revision : null
});
if (!string.IsNullOrWhiteSpace(search))
@@ -113,6 +116,7 @@ namespace FabWorks.Api.Controllers
ContentHash = r.PdfContentHash,
FileType = "pdf",
DrawingNumber = r.DrawingNumber,
Revision = r.DrawingRevision,
CreatedAt = r.ExportedAt
});
}
@@ -179,6 +183,7 @@ namespace FabWorks.Api.Controllers
public string FileType { get; set; }
public string DrawingNumber { get; set; }
public double? Thickness { get; set; }
public int? Revision { get; set; }
public DateTime CreatedAt { get; set; }
}
}
+50 -92
View File
@@ -32,7 +32,7 @@ const pages = {
setPage('Exports', `${data.items.length} exports`);
const rows = data.items.map((e, i) => `
<tr class="clickable" onclick="router.go('export-detail', {id: ${e.id}})" style="animation: fadeSlideIn 0.2s ease ${0.02 * Math.min(i, 25)}s forwards; opacity: 0">
<tr class="clickable" onclick="router.go('drawing-detail', {id: '${encodeURIComponent(e.drawingNumber)}', eid: '${e.id}'})" style="animation: fadeSlideIn 0.2s ease ${0.02 * Math.min(i, 25)}s forwards; opacity: 0">
<td style="font-family:var(--font-mono);color:var(--text-dim);font-size:13px">${e.id}</td>
<td><strong>${esc(e.drawingNumber) || '<span style="color:var(--text-dim)">\u2014</span>'}</strong></td>
<td style="color:var(--text-secondary);font-size:13px">${esc(e.title) || ''}</td>
@@ -62,85 +62,6 @@ const pages = {
}
},
async exportDetail(id) {
const actions = document.getElementById('topbar-actions');
const content = document.getElementById('page-content');
setPage('Loading...');
actions.innerHTML = '';
content.innerHTML = `<div class="loading">Loading export</div>`;
try {
const exp = await api.get(`/api/exports/${id}`);
setPage(exp.drawingNumber || `Export #${exp.id}`, 'export detail');
const dxfCount = (exp.bomItems || []).filter(b => b.cutTemplate?.contentHash).length;
const bomRows = (exp.bomItems || []).map((b, i) => {
const hasDetails = b.cutTemplate || b.formProgram;
const toggleId = `bom-${b.id}`;
return `
<tr class="${hasDetails ? 'clickable' : ''}" ${hasDetails ? `onclick="toggleBomRow('${toggleId}')"` : ''} style="animation: fadeSlideIn 0.25s ease ${0.03 * i}s forwards; opacity: 0">
<td style="width:32px">${hasDetails ? `<span class="chevron-toggle" id="${toggleId}-icon">${icons.chevron}</span>` : ''}</td>
<td style="font-family:var(--font-mono);font-weight:600;color:var(--cyan)">${esc(b.itemNo)}</td>
<td><strong>${esc(b.partName)}</strong></td>
<td style="color:var(--text-secondary)">${esc(b.description)}</td>
<td><span style="font-family:var(--font-mono);font-size:13px">${esc(b.material)}</span></td>
<td style="font-family:var(--font-mono);text-align:center">${b.qty ?? ''}</td>
<td style="font-family:var(--font-mono);text-align:center">${b.totalQty ?? ''}</td>
<td>
${b.cutTemplate ? `<span class="badge badge-cyan">${icons.laser} DXF</span>` : ''}
${b.formProgram ? `<span class="badge badge-amber">${icons.bend} Form</span>` : ''}
</td>
</tr>
${hasDetails ? `<tr class="bom-expand-row" id="${toggleId}" style="display:none"><td colspan="8">${renderBomDetails(b)}</td></tr>` : ''}`;
}).join('');
content.innerHTML = `
<a class="back-link" onclick="router.go('exports')">${icons.back} Back to exports</a>
<div class="card animate-in" style="margin-bottom:20px">
<div class="card-header">Export Information</div>
<div class="card-body">
<div class="detail-grid">
<div class="detail-field"><label>Drawing Number</label><div class="value">${esc(exp.drawingNumber) || '\u2014'}</div></div>
${exp.title ? `<div class="detail-field"><label>Title</label><div class="value">${esc(exp.title)}</div></div>` : ''}
<div class="detail-field"><label>Exported By</label><div class="value">${esc(exp.exportedBy)}</div></div>
<div class="detail-field"><label>Date</label><div class="value mono">${fmtDate(exp.exportedAt)}</div></div>
<div class="detail-field"><label>Source File</label><div class="value mono">${esc(exp.sourceFilePath)}</div></div>
</div>
</div>
</div>
<div class="card animate-in">
<div class="card-header">
BOM Items
<span class="badge badge-count">${exp.bomItems?.length || 0} items</span>
<span style="margin-left:auto;display:flex;gap:6px">
${exp.pdfContentHash ? `<a class="btn btn-amber btn-sm" href="/api/filebrowser/download?hash=${encodeURIComponent(exp.pdfContentHash)}&ext=pdf&name=${encodeURIComponent((exp.drawingNumber || 'drawing') + '.pdf')}">${icons.download} PDF</a>` : ''}
${dxfCount > 0 ? `<a class="btn btn-cyan btn-sm" href="/api/exports/${exp.id}/download-dxfs">${icons.download} All DXFs</a>` : ''}
<button class="btn btn-red btn-sm" onclick="deleteExport(${exp.id})">${icons.trash} Delete</button>
</span>
</div>
${exp.bomItems?.length ? `
<table>
<thead><tr>
<th style="width:32px"></th>
<th style="width:60px">Item</th>
<th>Part Name</th>
<th>Description</th>
<th>Material</th>
<th style="width:50px;text-align:center">Qty</th>
<th style="width:55px;text-align:center">Total</th>
<th style="width:120px">Data</th>
</tr></thead>
<tbody>${bomRows}</tbody>
</table>` : '<div class="empty">No BOM items for this export.</div>'}
</div>`;
} catch (err) {
content.innerHTML = `<div class="empty">Error: ${esc(err.message)}</div>`;
}
},
async drawings(params) {
const actions = document.getElementById('topbar-actions');
const content = document.getElementById('page-content');
@@ -255,8 +176,9 @@ const pages = {
}
},
async drawingDetail(drawingEncoded) {
async drawingDetail(drawingEncoded, params) {
const drawingNumber = decodeURIComponent(drawingEncoded);
const exportId = params?.eid ? parseInt(params.eid) : null;
const actions = document.getElementById('topbar-actions');
const content = document.getElementById('page-content');
setPage(drawingNumber, 'drawing');
@@ -273,12 +195,23 @@ const pages = {
return;
}
const allBom = [];
exports.forEach(exp => {
(exp.bomItems || []).forEach(b => {
allBom.push({ ...b, exportId: exp.id, exportedAt: exp.exportedAt });
let allBom;
const singleExport = exportId ? exports.find(e => e.id === exportId) : null;
if (singleExport) {
// Viewing a specific export - show only its BOM items
allBom = (singleExport.bomItems || []).map(b => ({ ...b, exportId: singleExport.id, exportedAt: singleExport.exportedAt }));
} else {
// Viewing drawing overview - deduplicate by itemNo, keeping latest revision (exports are newest-first)
const bomByItem = new Map();
exports.forEach(exp => {
(exp.bomItems || []).forEach(b => {
if (!bomByItem.has(b.itemNo)) {
bomByItem.set(b.itemNo, { ...b, exportId: exp.id, exportedAt: exp.exportedAt });
}
});
});
});
allBom = [...bomByItem.values()];
}
const bomRows = allBom.map((b, i) => {
const hasDetails = b.cutTemplate || b.formProgram;
@@ -288,6 +221,7 @@ const pages = {
<td style="width:32px">${hasDetails ? `<span class="chevron-toggle" id="${toggleId}-icon">${icons.chevron}</span>` : ''}</td>
<td style="font-family:var(--font-mono);font-weight:600;color:var(--cyan)">${esc(b.itemNo)}</td>
<td><strong>${esc(b.partName)}</strong></td>
<td style="font-family:var(--font-mono);text-align:center;font-size:13px">${b.cutTemplate?.revision ?? ''}</td>
<td style="color:var(--text-secondary)">${esc(b.description)}</td>
<td><span style="font-family:var(--font-mono);font-size:13px">${esc(b.material)}</span></td>
<td style="font-family:var(--font-mono);text-align:center">${b.qty ?? ''}</td>
@@ -297,22 +231,43 @@ const pages = {
${b.formProgram ? `<span class="badge badge-amber">${icons.bend} Form</span>` : ''}
</td>
</tr>
${hasDetails ? `<tr class="bom-expand-row" id="${toggleId}" style="display:none"><td colspan="8">${renderBomDetails(b)}</td></tr>` : ''}`;
${hasDetails ? `<tr class="bom-expand-row" id="${toggleId}" style="display:none"><td colspan="9">${renderBomDetails(b)}</td></tr>` : ''}`;
}).join('');
content.innerHTML = `
<a class="back-link" onclick="router.go('drawings')">${icons.back} Back to drawings</a>
const backLink = singleExport
? `<a class="back-link" onclick="router.go('exports')">${icons.back} Back to exports</a>`
: `<a class="back-link" onclick="router.go('drawings')">${icons.back} Back to drawings</a>`;
<div class="stats-grid">
const statsHtml = singleExport
? `<div class="stats-grid">
<div class="stat-card animate-in"><div class="stat-label">Exported By</div><div class="stat-value stat-sm">${esc(singleExport.exportedBy)}</div></div>
<div class="stat-card animate-in"><div class="stat-label">BOM Items</div><div class="stat-value">${allBom.length}</div></div>
<div class="stat-card animate-in"><div class="stat-label">Exported</div><div class="stat-value stat-sm">${fmtDate(singleExport.exportedAt)}</div></div>
</div>`
: `<div class="stats-grid">
<div class="stat-card animate-in"><div class="stat-label">Exports</div><div class="stat-value">${exports.length}</div></div>
<div class="stat-card animate-in"><div class="stat-label">BOM Items</div><div class="stat-value">${allBom.length}</div></div>
<div class="stat-card animate-in"><div class="stat-label">Latest Export</div><div class="stat-value stat-sm">${fmtDate(exports[0].exportedAt)}</div></div>
</div>
</div>`;
const bomHeader = singleExport ? 'BOM Items' : 'All BOM Items';
const activeExport = singleExport || exports[0];
const dxfCount = allBom.filter(b => b.cutTemplate?.contentHash).length;
const pdfHash = activeExport.pdfContentHash;
const pdfName = encodeURIComponent((drawingNumber || 'drawing') + '.pdf');
content.innerHTML = `
${backLink}
${statsHtml}
<div class="card animate-in">
<div class="card-header">
All BOM Items
${bomHeader}
<span class="badge badge-count">${allBom.length} items</span>
<span style="margin-left:auto;display:flex;gap:6px">
${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>` : ''}
</span>
</div>
${allBom.length ? `
<table>
@@ -320,6 +275,7 @@ const pages = {
<th style="width:32px"></th>
<th style="width:60px">Item</th>
<th>Part Name</th>
<th style="width:45px;text-align:center">Rev</th>
<th>Description</th>
<th>Material</th>
<th style="width:50px;text-align:center">Qty</th>
@@ -397,6 +353,7 @@ const pages = {
<td><div class="file-name-cell">${ext === 'pdf' ? icons.filePdf : icons.fileDxf}<a href="/api/filebrowser/download?hash=${encodeURIComponent(f.contentHash)}&ext=${ext}&name=${encodeURIComponent(f.fileName)}">${esc(f.fileName)}</a></div></td>
<td><span class="badge ${ext === 'dxf' ? 'badge-cyan' : 'badge-amber'}">${ext.toUpperCase()}</span></td>
<td style="color:var(--text-secondary)">${esc(f.drawingNumber)}</td>
<td style="font-family:var(--font-mono);font-size:13px;text-align:center;color:var(--text-secondary)">${f.revision != null ? f.revision : '\u2014'}</td>
<td style="font-family:var(--font-mono);font-size:13px;color:var(--text-secondary)">${f.thickness != null ? f.thickness.toFixed(4) + '"' : '\u2014'}</td>
<td style="font-family:var(--font-mono);font-size:13px;color:var(--text-secondary)">${fmtDate(f.createdAt)}</td>
<td style="font-family:var(--font-mono);font-size:12px;color:var(--text-dim)">${esc(hashShort)}</td>
@@ -413,6 +370,7 @@ const pages = {
<th>Name</th>
<th style="width:60px">Type</th>
<th>Drawing</th>
<th style="width:45px;text-align:center">Rev</th>
<th style="width:90px">Thickness</th>
<th style="width:170px">Date</th>
<th style="width:100px">Hash</th>
+4 -3
View File
@@ -1,6 +1,9 @@
const router = {
go(page, params = {}) {
const hash = page + (params.id ? '/' + params.id : '') + (params.q ? '?q=' + encodeURIComponent(params.q) : '');
const qParts = [];
if (params.q) qParts.push('q=' + encodeURIComponent(params.q));
if (params.eid) qParts.push('eid=' + encodeURIComponent(params.eid));
const hash = page + (params.id ? '/' + params.id : '') + (qParts.length ? '?' + qParts.join('&') : '');
location.hash = hash;
},
parse() {
@@ -20,12 +23,10 @@ const router = {
document.querySelectorAll('.nav-item').forEach(el => {
el.classList.toggle('active',
el.dataset.page === page ||
(page === 'export-detail' && el.dataset.page === 'exports') ||
(page === 'drawing-detail' && el.dataset.page === 'drawings'));
});
switch(page) {
case 'exports': pages.exports(params); break;
case 'export-detail': pages.exportDetail(id); break;
case 'drawings': pages.drawings(params); break;
case 'drawing-detail': pages.drawingDetail(id, params); break;
case 'files': pages.files(params); break;
+15
View File
@@ -9,6 +9,7 @@ namespace FabWorks.Core.Data
public DbSet<BomItem> BomItems { get; set; }
public DbSet<CutTemplate> CutTemplates { get; set; }
public DbSet<FormProgram> FormPrograms { get; set; }
public DbSet<Drawing> Drawings { get; set; }
public FabWorksDbContext(DbContextOptions<FabWorksDbContext> options) : base(options) { }
@@ -32,6 +33,11 @@ namespace FabWorks.Core.Data
.WithOne(b => b.ExportRecord)
.HasForeignKey(b => b.ExportRecordId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(e => e.Drawing)
.WithMany(d => d.ExportRecords)
.HasForeignKey(e => e.DrawingId)
.OnDelete(DeleteBehavior.SetNull);
});
modelBuilder.Entity<BomItem>(entity =>
@@ -74,6 +80,15 @@ namespace FabWorks.Core.Data
entity.Property(e => e.LowerToolNames).HasMaxLength(500);
entity.Property(e => e.SetupNotes).HasMaxLength(2000);
});
modelBuilder.Entity<Drawing>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.DrawingNumber).HasMaxLength(100);
entity.Property(e => e.Title).HasMaxLength(200);
entity.Property(e => e.PdfContentHash).HasMaxLength(64);
entity.HasIndex(e => e.DrawingNumber).IsUnique();
});
}
}
}
@@ -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("20260220125334_AddDrawingEntity")]
partial class AddDrawingEntity
{
/// <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<int>("Revision")
.HasColumnType("int");
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<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
}
}
}
@@ -0,0 +1,75 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace FabWorks.Core.Migrations
{
/// <inheritdoc />
public partial class AddDrawingEntity : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "DrawingId",
table: "ExportRecords",
type: "int",
nullable: true);
migrationBuilder.CreateTable(
name: "Drawings",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
DrawingNumber = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
Title = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
PdfContentHash = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
Revision = table.Column<int>(type: "int", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Drawings", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_ExportRecords_DrawingId",
table: "ExportRecords",
column: "DrawingId");
migrationBuilder.CreateIndex(
name: "IX_Drawings_DrawingNumber",
table: "Drawings",
column: "DrawingNumber",
unique: true,
filter: "[DrawingNumber] IS NOT NULL");
migrationBuilder.AddForeignKey(
name: "FK_ExportRecords_Drawings_DrawingId",
table: "ExportRecords",
column: "DrawingId",
principalTable: "Drawings",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_ExportRecords_Drawings_DrawingId",
table: "ExportRecords");
migrationBuilder.DropTable(
name: "Drawings");
migrationBuilder.DropIndex(
name: "IX_ExportRecords_DrawingId",
table: "ExportRecords");
migrationBuilder.DropColumn(
name: "DrawingId",
table: "ExportRecords");
}
}
}
@@ -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("20260220130029_SeedDrawingsFromExistingExports")]
partial class SeedDrawingsFromExistingExports
{
/// <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<int>("Revision")
.HasColumnType("int");
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<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
}
}
}
@@ -0,0 +1,51 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace FabWorks.Core.Migrations
{
/// <inheritdoc />
public partial class SeedDrawingsFromExistingExports : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Create Drawing records from existing ExportRecords (latest hash per DrawingNumber)
migrationBuilder.Sql(@"
INSERT INTO Drawings (DrawingNumber, Title, PdfContentHash, Revision)
SELECT
sub.DrawingNumber,
sub.Title,
sub.PdfContentHash,
1
FROM (
SELECT
e.DrawingNumber,
e.Title,
e.PdfContentHash,
ROW_NUMBER() OVER (PARTITION BY e.DrawingNumber ORDER BY e.Id DESC) AS rn
FROM ExportRecords e
WHERE e.DrawingNumber IS NOT NULL
AND e.PdfContentHash IS NOT NULL
) sub
WHERE sub.rn = 1;
");
// Link ExportRecords to their Drawing
migrationBuilder.Sql(@"
UPDATE er
SET er.DrawingId = d.Id
FROM ExportRecords er
INNER JOIN Drawings d ON d.DrawingNumber = er.DrawingNumber
WHERE er.PdfContentHash IS NOT NULL;
");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("UPDATE ExportRecords SET DrawingId = NULL;");
migrationBuilder.Sql("DELETE FROM Drawings;");
}
}
}
@@ -116,6 +116,38 @@ namespace FabWorks.Core.Migrations
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<int>("Revision")
.HasColumnType("int");
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")
@@ -124,6 +156,9 @@ namespace FabWorks.Core.Migrations
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int?>("DrawingId")
.HasColumnType("int");
b.Property<string>("DrawingNo")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
@@ -161,6 +196,8 @@ namespace FabWorks.Core.Migrations
b.HasKey("Id");
b.HasIndex("DrawingId");
b.ToTable("ExportRecords");
});
@@ -242,6 +279,16 @@ namespace FabWorks.Core.Migrations
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")
@@ -260,6 +307,11 @@ namespace FabWorks.Core.Migrations
b.Navigation("FormProgram");
});
modelBuilder.Entity("FabWorks.Core.Models.Drawing", b =>
{
b.Navigation("ExportRecords");
});
modelBuilder.Entity("FabWorks.Core.Models.ExportRecord", b =>
{
b.Navigation("BomItems");
+15
View File
@@ -0,0 +1,15 @@
using System.Collections.Generic;
namespace FabWorks.Core.Models
{
public class Drawing
{
public int Id { get; set; }
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>();
}
}
+3
View File
@@ -16,6 +16,9 @@ namespace FabWorks.Core.Models
public string ExportedBy { get; set; }
public string PdfContentHash { get; set; }
public int? DrawingId { get; set; }
public virtual Drawing Drawing { get; set; }
public virtual ICollection<BomItem> BomItems { get; set; } = new List<BomItem>();
}
}
@@ -0,0 +1,32 @@
# Auto-fill Equipment/Drawing from Export History
## Problem
When a SolidWorks file is opened that doesn't match the `DrawingInfo` regex (e.g., `Conveyor Frame.sldasm` instead of `5028 A02 Conveyor.slddrw`), the equipment and drawing number dropdowns are left empty even though the file may have been exported before with known values.
## Decision
- **Lookup key:** SolidWorks source file path (`ActiveDocument.FilePath`)
- **Storage:** Query the existing `ExportRecords` table (no new table or migration)
- **Priority:** Database lookup first; fall back to title regex parse if no history found
## Design
### Modify `UpdateActiveDocumentDisplay()` in `MainForm.cs`
When the active document changes:
1. Query the DB for the most recent `ExportRecord` where `SourceFilePath` matches `activeDoc.FilePath` (case-insensitive)
2. If found, parse the stored `DrawingNumber` via `DrawingInfo.Parse()` and auto-fill equipment/drawing dropdowns
3. If not found, fall back to current behavior: `DrawingInfo.Parse(activeDoc.Title)`
### What doesn't change
- No schema changes, no new migration
- Equipment/drawing dropdowns still populated with historical values in `InitializeDrawingDropdowns()`
- Export flow untouched
### Error handling
- DB query wrapped in try/catch so failures don't break the UI
- Case-insensitive path comparison (Windows paths are case-insensitive)
File diff suppressed because it is too large Load Diff