From b472729fdaabf9158e85aa9a63650347eb0a7258 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 20 Feb 2026 08:52:06 -0500 Subject: [PATCH] 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 --- ExportDXF/ExportDXF.csproj | 3 + ExportDXF/Services/DxfExportService.cs | 101 +++++++++++++++++++++---- ExportDXF/Utilities/ContentHasher.cs | 35 +++++++++ 3 files changed, 125 insertions(+), 14 deletions(-) diff --git a/ExportDXF/ExportDXF.csproj b/ExportDXF/ExportDXF.csproj index fd1c36f..e68e775 100644 --- a/ExportDXF/ExportDXF.csproj +++ b/ExportDXF/ExportDXF.csproj @@ -14,6 +14,9 @@ + + + diff --git a/ExportDXF/Services/DxfExportService.cs b/ExportDXF/Services/DxfExportService.cs index 5f8a67b..f6528be 100644 --- a/ExportDXF/Services/DxfExportService.cs +++ b/ExportDXF/Services/DxfExportService.cs @@ -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); diff --git a/ExportDXF/Utilities/ContentHasher.cs b/ExportDXF/Utilities/ContentHasher.cs index 4b08925..318e359 100644 --- a/ExportDXF/Utilities/ContentHasher.cs +++ b/ExportDXF/Utilities/ContentHasher.cs @@ -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 } } + /// + /// 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. + /// + 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(pngStream)) + { + var algorithm = new DifferenceHash(); + var hash = algorithm.Hash(image); + return hash.ToString("x16"); + } + } + } + catch + { + return ComputeFileHash(filePath); + } + } + /// /// Computes a SHA256 hash of the entire file contents (for PDFs and other binary files). ///