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>
This commit is contained in:
2026-02-20 08:52:06 -05:00
parent 5d2948d563
commit b472729fda
3 changed files with 125 additions and 14 deletions

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>

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);

View File

@@ -7,6 +7,11 @@ 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
{
@@ -30,6 +35,36 @@ namespace ExportDXF.Utilities
}
}
/// <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);
}
}
/// <summary>
/// Computes a SHA256 hash of the entire file contents (for PDFs and other binary files).
/// </summary>