diff --git a/ExportDXF/Services/ExcelExportService.cs b/ExportDXF/Services/ExcelExportService.cs
new file mode 100644
index 0000000..d097b49
--- /dev/null
+++ b/ExportDXF/Services/ExcelExportService.cs
@@ -0,0 +1,139 @@
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using ClosedXML.Excel;
+using ExportDXF.Models;
+
+namespace ExportDXF.Services
+{
+ public class ExcelExportService
+ {
+ ///
+ /// Reads existing Cut Templates from an xlsx file to compare content hashes.
+ /// Returns empty dictionary if file doesn't exist or has no Cut Templates sheet.
+ /// Key = Item #, Value = (ContentHash, Revision, FileName)
+ ///
+ public Dictionary ReadExistingCutTemplates(string xlsxPath)
+ {
+ var result = new Dictionary();
+
+ if (!File.Exists(xlsxPath))
+ return result;
+
+ using (var workbook = new XLWorkbook(xlsxPath))
+ {
+ if (!workbook.TryGetWorksheet("Cut Templates", out var ws))
+ return result;
+
+ var lastCol = ws.LastColumnUsed()?.ColumnNumber() ?? 0;
+ var lastRow = ws.LastRowUsed()?.RowNumber() ?? 1;
+
+ if (lastCol == 0 || lastRow <= 1)
+ return result;
+
+ var headers = new Dictionary();
+ for (int col = 1; col <= lastCol; col++)
+ {
+ var header = ws.Cell(1, col).GetString();
+ if (!string.IsNullOrEmpty(header))
+ headers[header] = col;
+ }
+
+ if (!headers.ContainsKey("Item #") || !headers.ContainsKey("Content Hash"))
+ return result;
+
+ for (int row = 2; row <= lastRow; row++)
+ {
+ var itemNo = ws.Cell(row, headers["Item #"]).GetString();
+ var hash = ws.Cell(row, headers["Content Hash"]).GetString();
+ var revision = headers.ContainsKey("Revision")
+ ? ws.Cell(row, headers["Revision"]).GetValue()
+ : 1;
+ var fileName = headers.ContainsKey("File Name")
+ ? ws.Cell(row, headers["File Name"]).GetString()
+ : "";
+
+ if (!string.IsNullOrEmpty(itemNo))
+ result[itemNo] = (hash, revision, fileName);
+ }
+ }
+
+ return result;
+ }
+
+ ///
+ /// Writes or updates the xlsx file with BOM and Cut Templates sheets.
+ /// rawBomTable: list of rows where each row is a dictionary of column name → value.
+ /// If null or empty, the BOM sheet is not written (Part/Assembly exports).
+ ///
+ public void Write(
+ string xlsxPath,
+ List> rawBomTable,
+ List bomItems)
+ {
+ using (var workbook = File.Exists(xlsxPath)
+ ? new XLWorkbook(xlsxPath)
+ : new XLWorkbook())
+ {
+ WriteBomSheet(workbook, rawBomTable);
+ WriteCutTemplatesSheet(workbook, bomItems);
+ workbook.SaveAs(xlsxPath);
+ }
+ }
+
+ private void WriteBomSheet(XLWorkbook workbook, List> rawBomTable)
+ {
+ if (rawBomTable == null || rawBomTable.Count == 0)
+ return;
+
+ if (workbook.TryGetWorksheet("BOM", out _))
+ workbook.Worksheets.Delete("BOM");
+
+ var sheet = workbook.Worksheets.Add("BOM");
+ var columns = rawBomTable[0].Keys.ToList();
+
+ for (int col = 0; col < columns.Count; col++)
+ sheet.Cell(1, col + 1).Value = columns[col];
+
+ for (int row = 0; row < rawBomTable.Count; row++)
+ {
+ for (int col = 0; col < columns.Count; col++)
+ {
+ string value;
+ rawBomTable[row].TryGetValue(columns[col], out value);
+ sheet.Cell(row + 2, col + 1).Value = value ?? "";
+ }
+ }
+
+ sheet.Columns().AdjustToContents();
+ }
+
+ private void WriteCutTemplatesSheet(XLWorkbook workbook, List bomItems)
+ {
+ if (workbook.TryGetWorksheet("Cut Templates", out _))
+ workbook.Worksheets.Delete("Cut Templates");
+
+ var sheet = workbook.Worksheets.Add("Cut Templates");
+
+ var headers = new[] { "Item #", "File Name", "Revision", "Thickness", "K-Factor", "Bend Radius", "Content Hash" };
+ for (int col = 0; col < headers.Length; col++)
+ sheet.Cell(1, col + 1).Value = headers[col];
+
+ int row = 2;
+ foreach (var item in bomItems.Where(b => b.CutTemplate != null).OrderBy(b => b.ItemNo))
+ {
+ var ct = item.CutTemplate;
+ sheet.Cell(row, 1).Value = item.ItemNo;
+ sheet.Cell(row, 2).Value = ct.DxfFilePath;
+ sheet.Cell(row, 3).Value = ct.Revision;
+ sheet.Cell(row, 4).Value = ct.Thickness ?? 0;
+ sheet.Cell(row, 5).Value = ct.KFactor ?? 0;
+ sheet.Cell(row, 6).Value = ct.DefaultBendRadius ?? 0;
+ sheet.Cell(row, 7).Value = ct.ContentHash ?? "";
+ row++;
+ }
+
+ sheet.Columns().AdjustToContents();
+ }
+ }
+}
diff --git a/ExportDXF/Services/LogFileService.cs b/ExportDXF/Services/LogFileService.cs
new file mode 100644
index 0000000..2381af7
--- /dev/null
+++ b/ExportDXF/Services/LogFileService.cs
@@ -0,0 +1,60 @@
+using System;
+using System.IO;
+
+namespace ExportDXF.Services
+{
+ public class LogFileService : IDisposable
+ {
+ private StreamWriter _exportLog;
+ private static readonly string AppLogPath = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "ExportDXF", "ExportDXF.log");
+
+ public void StartExportLog(string logFilePath)
+ {
+ _exportLog?.Dispose();
+
+ var dir = Path.GetDirectoryName(logFilePath);
+ if (!Directory.Exists(dir))
+ Directory.CreateDirectory(dir);
+
+ _exportLog = new StreamWriter(logFilePath, append: true);
+ }
+
+ public void Log(string level, string message)
+ {
+ var line = $"{DateTime.Now:yyyy-MM-dd HH:mm:ss} [{level}] {message}";
+
+ _exportLog?.WriteLine(line);
+ _exportLog?.Flush();
+
+ WriteAppLog(line);
+ }
+
+ public void LogInfo(string message) => Log("INFO", message);
+ public void LogWarning(string message) => Log("WARNING", message);
+ public void LogError(string message) => Log("ERROR", message);
+
+ private void WriteAppLog(string line)
+ {
+ try
+ {
+ var dir = Path.GetDirectoryName(AppLogPath);
+ if (!Directory.Exists(dir))
+ Directory.CreateDirectory(dir);
+
+ File.AppendAllText(AppLogPath, line + Environment.NewLine);
+ }
+ catch
+ {
+ // Best-effort app log — don't fail exports if log write fails
+ }
+ }
+
+ public void Dispose()
+ {
+ _exportLog?.Dispose();
+ _exportLog = null;
+ }
+ }
+}
diff --git a/ExportDXF/Services/RawBomTableReader.cs b/ExportDXF/Services/RawBomTableReader.cs
new file mode 100644
index 0000000..9c0c986
--- /dev/null
+++ b/ExportDXF/Services/RawBomTableReader.cs
@@ -0,0 +1,48 @@
+using System.Collections.Generic;
+using SolidWorks.Interop.sldworks;
+
+namespace ExportDXF.Services
+{
+ ///
+ /// Reads all visible columns and rows from a SolidWorks BOM table annotation
+ /// as raw string data for direct copy into an Excel sheet.
+ ///
+ public static class RawBomTableReader
+ {
+ public static List> Read(BomTableAnnotation bomTable)
+ {
+ var table = (TableAnnotation)bomTable;
+ var rows = new List>();
+
+ int colCount = table.ColumnCount;
+ int rowCount = table.RowCount;
+
+ // Build visible column headers
+ var columns = new List<(int Index, string Header)>();
+ for (int col = 0; col < colCount; col++)
+ {
+ if (table.ColumnHidden[col])
+ continue;
+
+ var header = table.get_Text(0, col)?.Trim() ?? $"Column{col}";
+ columns.Add((col, header));
+ }
+
+ // Read data rows (skip header row 0, skip hidden rows)
+ for (int row = 1; row < rowCount; row++)
+ {
+ if (table.RowHidden[row])
+ continue;
+
+ var rowData = new Dictionary();
+ foreach (var (colIdx, header) in columns)
+ {
+ rowData[header] = table.get_Text(row, colIdx)?.Trim() ?? "";
+ }
+ rows.Add(rowData);
+ }
+
+ return rows;
+ }
+ }
+}