using System; using System.Collections.Generic; using System.Linq; namespace FurnitureExporter; /// /// 安装图样识别器。 /// /// 设计口径: /// - 吸附面(mount face)先缩小候选范围; /// - HoleGroup 负责把几何孔组织成业务孔位; /// - MountPattern 负责定义“至少命中几个孔才允许吸附成立”。 /// public static class MountPatternAnalyzer { private const double DefaultClusterDistanceMm = 220.0; public static List BuildMountPatterns( IReadOnlyList panels, IReadOnlyList holes, IReadOnlyList holeGroups) { var patterns = new List(); if (panels == null || panels.Count == 0) { return patterns; } var holesByPanel = holes .Where(h => !string.IsNullOrWhiteSpace(h.PanelId)) .GroupBy(h => h.PanelId, StringComparer.OrdinalIgnoreCase) .ToDictionary(group => group.Key, group => group.ToList(), StringComparer.OrdinalIgnoreCase); var groupsByPanel = holeGroups .Where(group => !string.IsNullOrWhiteSpace(group.PanelId)) .GroupBy(group => group.PanelId, StringComparer.OrdinalIgnoreCase) .ToDictionary(group => group.Key, group => group.ToList(), StringComparer.OrdinalIgnoreCase); foreach (var panel in panels) { var panelHoles = holesByPanel.TryGetValue(panel.Id, out var panelHoleList) ? panelHoleList : new List(); var panelGroups = groupsByPanel.TryGetValue(panel.Id, out var panelGroupList) ? panelGroupList : new List(); var units = BuildUnits(panel.Id, panelHoles, panelGroups); if (units.Count == 0) { panel.MountPatterns = new List(); panel.SnapPatterns = new List(); continue; } var panelPatterns = new List(); var unitsByFace = units .SelectMany(unit => unit.MountFaces.Select(face => new { Face = face, Unit = unit })) .GroupBy(item => item.Face, StringComparer.OrdinalIgnoreCase); foreach (var faceGroup in unitsByFace) { var clusters = BuildClusters(faceGroup.Select(item => item.Unit).Distinct().ToList()); var patternIndex = 0; foreach (var cluster in clusters) { var pattern = BuildPattern(panel.Id, faceGroup.Key, patternIndex++, cluster); panelPatterns.Add(pattern); } } panel.MountPatterns = panelPatterns; panel.SnapPatterns = panelPatterns; patterns.AddRange(panelPatterns); } return patterns; } public static void AttachPatternsToPanels(IReadOnlyList panels, IReadOnlyList patterns) { var map = patterns .GroupBy(pattern => pattern.PanelId, StringComparer.OrdinalIgnoreCase) .ToDictionary(group => group.Key, group => group.ToList(), StringComparer.OrdinalIgnoreCase); foreach (var panel in panels) { var panelPatterns = map.TryGetValue(panel.Id, out var items) ? items : new List(); panel.MountPatterns = panelPatterns; panel.SnapPatterns = panelPatterns; } } private static List BuildUnits( string panelId, IReadOnlyList holes, IReadOnlyList holeGroups) { var units = new List(); var groupedHoleIds = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var group in holeGroups) { foreach (var holeId in group.HoleIds ?? Array.Empty()) { groupedHoleIds.Add(holeId); } var mountFaces = NormalizeMountFaces(group.SurfaceFaces, group.MountFace); group.MountFace = mountFaces.FirstOrDefault() ?? "unknown"; units.Add(new MountPatternUnit { Id = group.Id, PanelId = panelId, HoleIds = group.HoleIds ?? Array.Empty(), Position = group.Position, Normal = group.Normal, GroupType = group.GroupType, MountFaces = mountFaces, Confidence = Clamp01(group.Confidence <= 0 ? 0.72 : group.Confidence), Warnings = group.Warnings?.ToList() ?? new List(), Source = "hole-group", }); } foreach (var hole in holes) { if (groupedHoleIds.Contains(hole.Id)) { continue; } units.Add(new MountPatternUnit { Id = hole.Id, PanelId = panelId, HoleIds = new[] { hole.Id }, Position = hole.Position, Normal = hole.Normal, GroupType = hole.HoleGroupType, MountFaces = NormalizeMountFaces(hole.SurfaceFaces), Confidence = Clamp01(hole.Confidence <= 0 ? 0.76 : hole.Confidence), Warnings = hole.Warnings?.ToList() ?? new List(), Source = "single-hole", }); } return units; } private static List> BuildClusters(IReadOnlyList units) { var clusters = new List>(); var visited = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var unit in units) { if (!visited.Add(unit.Id)) { continue; } var cluster = new List(); var queue = new Queue(); queue.Enqueue(unit); while (queue.Count > 0) { var current = queue.Dequeue(); cluster.Add(current); foreach (var candidate in units) { if (visited.Contains(candidate.Id)) { continue; } if (Distance(current.Position, candidate.Position) > DefaultClusterDistanceMm) { continue; } visited.Add(candidate.Id); queue.Enqueue(candidate); } } clusters.Add(cluster.OrderBy(item => item.Position[0]).ThenBy(item => item.Position[1]).ThenBy(item => item.Position[2]).ToList()); } return clusters; } private static MountPatternInfo BuildPattern( string panelId, string mountFace, int patternIndex, IReadOnlyList cluster) { var holeIds = cluster .SelectMany(unit => unit.HoleIds) .Distinct(StringComparer.OrdinalIgnoreCase) .ToArray(); var warnings = cluster .SelectMany(unit => unit.Warnings) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); var pointCount = cluster.Count; var patternType = ResolvePatternType(cluster); var isSymmetricRectangle = string.Equals(patternType, "rectangle_4", StringComparison.OrdinalIgnoreCase); var minRequiredHoles = ResolveMinRequiredHoleCount(patternType, pointCount, isSymmetricRectangle); var preferredHoleCount = pointCount; var requiredHoleIds = Array.Empty(); var optionalHoleIds = Array.Empty(); if (pointCount <= 3 || !isSymmetricRectangle) { requiredHoleIds = holeIds; } else { optionalHoleIds = holeIds; warnings.Add("四孔对称图样默认按 2 孔完成初始定位,剩余孔位作为增强固定。"); } if (string.Equals(patternType, "generic", StringComparison.OrdinalIgnoreCase)) { warnings.Add("当前图样由几何聚类自动生成,建议后续在资产录入阶段人工复核。"); } return new MountPatternInfo { Id = $"{panelId}_mount_{mountFace}_{patternIndex:D3}", PanelId = panelId, MountFace = mountFace, PatternType = patternType, HoleIds = holeIds, RequiredHoleIds = requiredHoleIds, OptionalHoleIds = optionalHoleIds, MinRequiredHoles = minRequiredHoles, PreferredHoleCount = preferredHoleCount, Symmetry = isSymmetricRectangle, Position = AveragePosition(cluster), Normal = ResolveMountFaceNormal(mountFace), Confidence = ComputePatternConfidence(cluster, patternType, warnings.Count), Warnings = warnings, }; } private static string ResolvePatternType(IReadOnlyList cluster) { return cluster.Count switch { 1 => "single", 2 => "line_2", 3 => "triangle_3", 4 when IsRectangle(cluster) => "rectangle_4", _ => "generic", }; } private static int ResolveMinRequiredHoleCount(string patternType, int pointCount, bool isSymmetricRectangle) { if (string.Equals(patternType, "single", StringComparison.OrdinalIgnoreCase)) { return 1; } if (string.Equals(patternType, "line_2", StringComparison.OrdinalIgnoreCase)) { return 2; } if (string.Equals(patternType, "triangle_3", StringComparison.OrdinalIgnoreCase)) { return 3; } if (isSymmetricRectangle) { return 2; } if (pointCount <= 0) { return 1; } return pointCount <= 4 ? pointCount : Math.Min(pointCount, 4); } private static bool IsRectangle(IReadOnlyList cluster) { if (cluster.Count != 4) { return false; } var distances = new List(); for (var i = 0; i < cluster.Count; i++) { for (var j = i + 1; j < cluster.Count; j++) { distances.Add(Distance(cluster[i].Position, cluster[j].Position)); } } distances.Sort(); if (distances.Count != 6) { return false; } var sideA = distances[0]; var sideB = distances[2]; var diagonalA = distances[4]; var diagonalB = distances[5]; return AreClose(sideA, distances[1], 0.12) && AreClose(sideB, distances[3], 0.12) && AreClose(diagonalA, diagonalB, 0.08); } private static double[] AveragePosition(IReadOnlyList cluster) { if (cluster.Count == 0) { return new[] { 0.0, 0.0, 0.0 }; } return new[] { Math.Round(cluster.Average(item => item.Position[0]), 2), Math.Round(cluster.Average(item => item.Position[1]), 2), Math.Round(cluster.Average(item => item.Position[2]), 2), }; } private static double[] ResolveMountFaceNormal(string mountFace) { return mountFace.ToLowerInvariant() switch { "left" => new[] { -1.0, 0.0, 0.0 }, "right" => new[] { 1.0, 0.0, 0.0 }, "front" => new[] { 0.0, -1.0, 0.0 }, "back" => new[] { 0.0, 1.0, 0.0 }, "bottom" => new[] { 0.0, 0.0, -1.0 }, "top" => new[] { 0.0, 0.0, 1.0 }, _ => new[] { 0.0, 0.0, 1.0 }, }; } private static double ComputePatternConfidence( IReadOnlyList cluster, string patternType, int warningCount) { if (cluster.Count == 0) { return 0; } var baseConfidence = cluster.Average(item => item.Confidence); if (string.Equals(patternType, "generic", StringComparison.OrdinalIgnoreCase)) { baseConfidence = Math.Min(baseConfidence, 0.68); } if (warningCount > 0) { baseConfidence -= Math.Min(0.14, warningCount * 0.04); } return Math.Round(Clamp01(baseConfidence), 4); } private static string[] NormalizeMountFaces(IEnumerable? faces, string? fallbackFace = null) { var normalized = (faces ?? Array.Empty()) .Where(face => !string.IsNullOrWhiteSpace(face)) .Select(face => face.Trim().ToLowerInvariant()) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); if (normalized.Count > 0) { return normalized.ToArray(); } if (!string.IsNullOrWhiteSpace(fallbackFace)) { return new[] { fallbackFace!.Trim().ToLowerInvariant() }; } return new[] { "unknown" }; } private static double Distance(IReadOnlyList left, IReadOnlyList right) { var dx = left[0] - right[0]; var dy = left[1] - right[1]; var dz = left[2] - right[2]; return Math.Sqrt(dx * dx + dy * dy + dz * dz); } private static bool AreClose(double left, double right, double toleranceRatio) { var baseValue = Math.Max(Math.Abs(left), Math.Abs(right)); if (baseValue <= 1e-6) { return true; } return Math.Abs(left - right) / baseValue <= toleranceRatio; } private static double Clamp01(double value) { if (value < 0) return 0; if (value > 1) return 1; return value; } private sealed class MountPatternUnit { public string Id { get; set; } = ""; public string PanelId { get; set; } = ""; public string[] HoleIds { get; set; } = Array.Empty(); public double[] Position { get; set; } = new double[3]; public double[] Normal { get; set; } = new double[3]; public string? GroupType { get; set; } public string[] MountFaces { get; set; } = Array.Empty(); public double Confidence { get; set; } public List Warnings { get; set; } = new(); public string Source { get; set; } = "single-hole"; } }