using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Diagnostics; using System.IO; using System.Linq; using System.Text; using System.Text.RegularExpressions; using System.Threading.Tasks; using System.Windows; using ACadSharp.Entities; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using CSMath; using Microsoft.Win32; namespace BoreholeExtract { public partial class MainViewModel : ObservableObject { // ================= 参数配置 ================= private const string FrameLayerName = "0"; private readonly string OutputDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Desktop), "提取钻孔数据"); // 行对齐容差 (用于地层数字提取,必须严格) private const double RowAlignmentTolerance = 4.0; // 表头提取容差 (普通短标签) private const double HeaderStrictDx = 30.0; private const double HeaderStrictDy = 5.0; private readonly ConfigService _configService = new ConfigService(); public ObservableCollection DashboardItems { get; } = new(); [ObservableProperty] public partial string Message { get; set; } public MainViewModel() { LoadData(); } private void LoadData() { // 1. 读取配置文件 (List) var savedConfigs = _configService.LoadConfig(); DashboardItems.Clear(); // 2. 遍历枚举的所有值:确保界面上包含枚举中定义的每一项 foreach (Identify id in Enum.GetValues(typeof(Identify))) { // 尝试从保存的配置中找到对应的项 var config = savedConfigs.FirstOrDefault(c => c.Id == id); ObservableCollection items; string title; if (config != null) { // 如果找到了保存的配置,用保存的数据 items = new ObservableCollection(config.Items); title = config.Title; // 使用保存的标题(如果允许改名)或重置为默认 } else { // 如果没找到(比如第一次运行,或者新加了枚举项),使用默认值 items = new ObservableCollection(); title = IdentityHelper.GetDefaultTitle(id); } // 创建包装器并添加到界面列表 DashboardItems.Add(new CategoryWrapper(id, title, items)); } } [RelayCommand] public void SaveConfig() { var configs = DashboardItems.Select(w => new CategoryConfig { Id = w.Id, // 保存枚举 ID Title = w.Title, // 保存标题 Items = w.Items.ToList() }).ToList(); _configService.SaveConfig(configs); } [RelayCommand] private async Task Extract() { OpenFileDialog dialog = new OpenFileDialog { Filter = "dwg文件(*.dwg)|*.dwg", Multiselect = true, }; if (dialog.ShowDialog() == true) { Action logAction = (msg) => { // ⚠️ 关键:强制切回 UI 线程更新集合 System.Windows.Application.Current.Dispatcher.Invoke(() => { Logs.Add(msg); }); }; if (!Directory.Exists(OutputDir)) { Directory.CreateDirectory(OutputDir); } foreach (var path in dialog.FileNames) { Logs.Add($"{DateTime.Now:HH:mm:ss} > 正在处理 {path}"); await Task.Run(() => ExportBoreholeHybrid(path, logAction)); } var result = MessageBox.Show("是否打开输出目录", "提示", MessageBoxButton.YesNo, MessageBoxImage.Question); if (result == MessageBoxResult.Yes) { Process.Start(OutputDir); } } } private class TextEntity { public string Text { get; set; } public XYZ Position { get; set; } public double X => Position.X; public double Y => Position.Y; public ulong Id { get; set; } } private class FrameInfo { public BoundingBox Bounds { get; set; } public string HoleNumber { get; set; } } public ObservableCollection Logs { get; } = new ObservableCollection(); ACadSharp.IO.DwgReader reader; private async void ExportBoreholeHybrid(string dwgPath, Action logCallback) { reader = new(dwgPath); var cadDocument = reader.Read(); List allTexts = new List(); //List rawFrames = new List(); var entities = cadDocument.Entities .Where( entity => (entity is ACadSharp.Entities.MText || entity is ACadSharp.Entities.TextEntity)) .ToList(); foreach (var entity in entities) { if (entity is MText mText) { allTexts.Add(new TextEntity { Text = mText.Value.Trim(), Position = mText.InsertPoint, Id = mText.Handle }); } if (entity is ACadSharp.Entities.TextEntity dbText) { allTexts.Add(new TextEntity { Text = dbText.Value.Trim(), Position = dbText.InsertPoint, Id = dbText.Handle }); } } var rawFrames = cadDocument.Entities .Where( entity => entity is LwPolyline polyline && polyline.ConstantWidth == 1.0 && polyline.Layer.Name == FrameLayerName).Select(e => e.GetBoundingBox()) .ToList(); // 2. 过滤嵌套图框 List uniqueFrames = FilterNestedFrames(rawFrames); if (uniqueFrames.Count == 0) { logCallback?.Invoke("\n未找到图框。"); return; } logCallback?.Invoke($"\n识别到 {uniqueFrames.Count} 个图框,正在进行孔号分组..."); // 3. 预扫描孔号并分组 var framesWithHole = PreScanHoleNumbers(uniqueFrames, allTexts); var groupedFrames = framesWithHole .Where(f => !string.IsNullOrEmpty(f.HoleNumber)) .GroupBy(f => f.HoleNumber) .ToList(); List finalLogs = new List(); // 4. 按孔号处理 foreach (var group in groupedFrames) { string currentHoleNo = group.Key; // A. 确定主表 (Master Frame) // 通常取列表中的第一个,或者Y坐标最高的那个(假设是第一页) // 我们这里简单取 List 的第一个,因为 PreScan 顺序通常对应选择顺序 var masterFrameInfo = group.First(); BoundingBox masterBounds = masterFrameInfo.Bounds; // B. 获取主表的文字 (用于提取 Header) var masterTexts = allTexts .Where(t => t.X >= masterBounds.Min.X && t.X <= masterBounds.Max.X && t.Y >= masterBounds.Min.Y && t.Y <= masterBounds.Max.Y) .ToList(); // C. 获取该孔所有表的文字 (用于提取 Layers) var allPageTexts = new List(); foreach (var f in group) { var pageTexts = allTexts .Where(t => t.X >= f.Bounds.Min.X && t.X <= f.Bounds.Max.X && t.Y >= f.Bounds.Min.Y && t.Y <= f.Bounds.Max.Y) .ToList(); allPageTexts.AddRange(pageTexts); } try { BoreholeLog log = new BoreholeLog(); // Step 1: 仅从主表提取 Header (避免空白表头干扰) log.Header = ExtractHeaderOnly(masterTexts); // 确保孔号存在 if (string.IsNullOrEmpty(log.Header.HoleNumber)) log.Header.HoleNumber = currentHoleNo; // Step 2: 从所有合并的文字中提取 Layers (解决跨页描述) log.Layers = ExtractLayersOnly(allPageTexts); // 排序 log.Layers = log.Layers.OrderBy(l => l.BottomDepth).ToList(); finalLogs.Add(log); } catch (Exception ex) { logCallback?.Invoke($"\n解析孔 {currentHoleNo} 失败: {ex.Message}"); } } // 输出 string fileName = Path.GetFileNameWithoutExtension(dwgPath); string path = Path.Combine(OutputDir, $"{fileName}_钻孔数据.csv"); //string json = JsonConvert.SerializeObject(finalLogs, Formatting.Indented); //File.WriteAllText(path, json); ExportToCsvManual(finalLogs, path); logCallback?.Invoke($"\n处理完成!共 {finalLogs.Count} 个钻孔。文件: {path}"); } // ---------------------------------------------------------------- // 模块 1: Header 提取 (仅针对单张表) // ---------------------------------------------------------------- private HeaderInfo ExtractHeaderOnly(List texts) { HeaderInfo h = new HeaderInfo(); var matcher = new KeywordMatcher(DashboardItems); foreach (var t in texts) { string txt = t.Text.Replace(" ", ""); // 🏢 勘察单位 if (matcher.IsMatch(Identify.Company, txt)) h.SurveyUnit = GetValueStrict(texts, t); // 📁 工程名称 if (matcher.IsMatch(Identify.Project, txt)) h.ProjectName = GetValueStrict(texts, t); // 🛣️ 里程 if (matcher.IsMatch(Identify.Mileage, txt)) h.Mileage = GetValueStrict(texts, t); // 📐 设计底板标高 if (matcher.IsMatch(Identify.DesignElevation, txt)) h.DesignElevation = ExtractDouble(GetValueStrict(texts, t)); // 🔢 钻孔编号 if (matcher.IsMatch(Identify.DrillCode, txt)) h.HoleNumber = GetValueStrict(texts, t); // 🏷️ 钻孔类别 if (matcher.IsMatch(Identify.DrillCategory, txt)) h.HoleType = GetValueStrict(texts, t); // ⛰️ 孔口标高 if (matcher.IsMatch(Identify.HoleElevation, txt)) h.HoleElevation = ExtractDouble(GetValueStrict(texts, t)); // 📏 孔口直径 if (matcher.IsMatch(Identify.HoleDiameter, txt)) h.Diameter = GetValueStrict(texts, t); // 📅 开工日期 if (matcher.IsMatch(Identify.StartDate, txt)) h.StartDate = GetValueStrict(texts, t); // 🏁 竣工日期 if (matcher.IsMatch(Identify.EndDate, txt)) h.EndDate = GetValueStrict(texts, t); // 🌊 稳定水位 if (matcher.IsMatch(Identify.StableWater, txt)) h.StableWaterLevel = ExtractDouble(GetValueStrict(texts, t)); // 💧 初见水位 if (matcher.IsMatch(Identify.InitialWater, txt)) h.InitialWaterLevel = ExtractDouble(GetValueStrict(texts, t)); if (txt.Contains("X=")) h.CoordinateX = ExtractDouble(txt); if (txt.Contains("Y=")) h.CoordinateY = ExtractDouble(txt); } return h; } // ---------------------------------------------------------------- // 模块 2: Layers 提取 (针对合并后的全量文字) // ---------------------------------------------------------------- private static List ExtractLayersOnly(List texts) { List layers = new List(); // 1. 找到所有层号 (Anchors) - 包含所有页面的 var layerAnchors = texts .Where(t => Regex.IsMatch(t.Text, @"^<[\d-]+>$")) .OrderByDescending(t => t.Y) // 注意:跨页合并后,Y坐标是绝对坐标,排序可能不完全代表深度顺序,但没关系 .ToList(); if (layerAnchors.Count == 0) { // Fallback: 纯数字编号 var numericAnchors = texts.Where(t => Regex.IsMatch(t.Text, @"^\d+(-\d+)?$")).ToList(); if (numericAnchors.Count > 0) { double minX = numericAnchors.Min(t => t.X); layerAnchors = numericAnchors .Where(t => t.X < minX + 200.0) .OrderByDescending(t => t.Y) .ToList(); } } foreach (var anchor in layerAnchors) { LayerData layer = new LayerData { LayerNo = anchor.Text }; // A. 提取数值 (标高/深度/厚度) // 必须严格限制在 anchor 的同一行 (Strict Y) var lineNumbers = texts .Where(t => t.Id != anchor.Id) .Where(t => t.X > anchor.X) .Where(t => Math.Abs(t.Y - anchor.Y) < RowAlignmentTolerance) // 严格同行 .Where(t => IsPureNumber(t.Text)) .OrderBy(t => t.X) .Take(3) .ToList(); if (lineNumbers.Count > 0) layer.BottomElevation = ExtractDouble(lineNumbers[0].Text); if (lineNumbers.Count > 1) layer.BottomDepth = ExtractDouble(lineNumbers[1].Text); if (lineNumbers.Count > 2) layer.Thickness = ExtractDouble(lineNumbers[2].Text); // B. 提取描述 // 全局搜索以 anchor.Text 开头的文字 string searchPrefix = anchor.Text.Trim(); var descEntity = texts .Where(t => t.Id != anchor.Id) .Where(t => t.Text.Trim().StartsWith(searchPrefix)) // 核心:前缀匹配 .OrderByDescending(t => t.Text.Length) // 取最长的,最稳 .FirstOrDefault(); if (descEntity != null) { layer.Description = descEntity.Text; } layers.Add(layer); } return layers; } public void ExportToCsvManual(List data, string filePath) { var sb = new StringBuilder(); // 1. 写表头 sb.AppendLine("BoreName,Easting,Northing,Elevation,HoleDepth,TopDepth,BottomDepth,Identifier"); // 2. 写数据 foreach (var hole in data) { foreach (var item in hole.Layers) { var line = string.Join( ",", Escape(hole.Header.HoleNumber), hole.Header.CoordinateY, // 数字不需要转义 hole.Header.CoordinateX, hole.Header.HoleElevation, hole.HoleDepth, Math.Round(hole.Header.HoleElevation - item.BottomElevation - item.Thickness, MidpointRounding.AwayFromZero), hole.Header.HoleElevation - item.BottomElevation, ExtractAndJoinFast(item.Description)); sb.AppendLine(line); } } // 3. 保存文件 (同样需要 UTF8 BOM) File.WriteAllText(filePath, sb.ToString(), new UTF8Encoding(true)); } public string ExtractAndJoin(string input) { // 正则解释: // < : 匹配左尖括号 // ([^>]+) : 第1组,匹配任意非右尖括号的字符(即 3-6) // > : 匹配右尖括号 // ([^::]+) : 第2组,匹配任意非冒号的字符(即 粉质黏土) // [::] : 匹配中文或英文冒号 string pattern = @"<([^>]+)>([^::]+)[::]"; Match match = Regex.Match(input, pattern); if (match.Success) { // match.Groups[1] 是尖括号里的内容 (3-6) // match.Groups[2] 是冒号前的内容 (粉质黏土) return $"{match.Groups[1].Value}-{match.Groups[2].Value}"; } return string.Empty; // 或者返回原字符串,视需求而定 } public string ExtractAndJoinFast(string input) { if (string.IsNullOrEmpty(input)) return ""; // 1. 找关键符号的位置 int leftBracket = input.IndexOf('<'); int rightBracket = input.IndexOf('>'); int colon = input.IndexOf(':'); // 注意这里是中文冒号 if (colon == -1) colon = input.IndexOf(':'); // 兼容英文冒号 // 2. 确保符号都存在且顺序正确 (< 在 > 前,> 在 : 前) if (leftBracket != -1 && rightBracket > leftBracket && colon > rightBracket) { // 提取 3-6 string part1 = input.Substring(leftBracket + 1, rightBracket - leftBracket - 1); // 提取 粉质黏土 string part2 = input.Substring(rightBracket + 1, colon - rightBracket - 1); return $"{part1}-{part2}"; } return ""; } // 核心转义函数:防止格式错乱 private string Escape(string content) { if (string.IsNullOrEmpty(content)) return ""; bool needsQuotes = false; // 检查是否包含特殊字符 if (content.Contains(",") || content.Contains("\"") || content.Contains("\r") || content.Contains("\n")) { needsQuotes = true; } // 将内部的一个双引号变成两个双引号 content = content.Replace("\"", "\"\""); // 如果包含特殊字符,首尾加引号 if (needsQuotes) { content = $"\"{content}\""; } return content; } // ---------------------------------------------------------------- // 辅助功能 // ---------------------------------------------------------------- private List PreScanHoleNumbers(List frames, List allTexts) { var result = new List(); foreach (var frame in frames) { var textsInFrame = allTexts .Where(t => frame.IsIn(t.Position)) .ToList(); if (textsInFrame.Count < 5) continue; // 尝试找孔号 string holeNo = ""; var labelNode = textsInFrame.FirstOrDefault(t => t.Text.Replace(" ", "").Contains("钻孔编号")); if (labelNode != null) { holeNo = GetValueStrict(textsInFrame, labelNode); } // 必须找到孔号才加入分组,否则可能是图例或其他表格 if (!string.IsNullOrEmpty(holeNo)) { result.Add(new FrameInfo { Bounds = frame, HoleNumber = holeNo }); } } return result; } private List FilterNestedFrames(List frames) { if (frames == null || frames.Count == 0) return new List(); frames.Sort((a, b) => { double areaA = (a.Max.X - a.Min.X) * (a.Max.Y - a.Min.Y); double areaB = (b.Max.X - b.Min.X) * (b.Max.Y - b.Min.Y); return areaB.CompareTo(areaA); }); List validFrames = new List(); foreach (var candidate in frames) { bool isNested = false; foreach (var existing in validFrames) { if (existing.IsIn(candidate)) { isNested = true; break; } //if (candidate.Min.X >= existing.Min.X && // candidate.Max.X <= existing.Max.X && // candidate.Min.Y >= existing.Min.Y && // candidate.Max.Y <= existing.Max.Y) //{ // isNested = true; break; //} } if (!isNested) validFrames.Add(candidate); } return validFrames; } private static string GetValueStrict(List allTexts, TextEntity label) { var target = allTexts .Where(t => t != label) .Where(t => t.X > label.X && t.X < label.X + HeaderStrictDx) .Where(t => Math.Abs(t.Y - label.Y) < HeaderStrictDy) .OrderBy(t => t.X) .FirstOrDefault(); return target?.Text ?? ""; } private static bool IsPureNumber(string text) => Regex.IsMatch(text.Trim(), @"^-?\d+(\.\d+)?$"); private static double ExtractDouble(string text) { if (string.IsNullOrEmpty(text)) return 0; var match = Regex.Match(text, @"-?\d+(\.\d+)?"); return match.Success ? double.Parse(match.Value) : 0; } } }