442 lines
14 KiB
C#
442 lines
14 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.Linq;
|
||
|
||
namespace FurnitureExporter;
|
||
|
||
/// <summary>
|
||
/// 安装图样识别器。
|
||
///
|
||
/// 设计口径:
|
||
/// - 吸附面(mount face)先缩小候选范围;
|
||
/// - HoleGroup 负责把几何孔组织成业务孔位;
|
||
/// - MountPattern 负责定义“至少命中几个孔才允许吸附成立”。
|
||
/// </summary>
|
||
public static class MountPatternAnalyzer
|
||
{
|
||
private const double DefaultClusterDistanceMm = 220.0;
|
||
|
||
public static List<MountPatternInfo> BuildMountPatterns(
|
||
IReadOnlyList<PanelInfo> panels,
|
||
IReadOnlyList<HoleInfo> holes,
|
||
IReadOnlyList<HoleGroupInfo> holeGroups)
|
||
{
|
||
var patterns = new List<MountPatternInfo>();
|
||
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<HoleInfo>();
|
||
var panelGroups = groupsByPanel.TryGetValue(panel.Id, out var panelGroupList)
|
||
? panelGroupList
|
||
: new List<HoleGroupInfo>();
|
||
|
||
var units = BuildUnits(panel.Id, panelHoles, panelGroups);
|
||
if (units.Count == 0)
|
||
{
|
||
panel.MountPatterns = new List<MountPatternInfo>();
|
||
continue;
|
||
}
|
||
|
||
var panelPatterns = new List<MountPatternInfo>();
|
||
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;
|
||
patterns.AddRange(panelPatterns);
|
||
}
|
||
|
||
return patterns;
|
||
}
|
||
|
||
public static void AttachPatternsToPanels(IReadOnlyList<PanelInfo> panels, IReadOnlyList<MountPatternInfo> patterns)
|
||
{
|
||
var map = patterns
|
||
.GroupBy(pattern => pattern.PanelId, StringComparer.OrdinalIgnoreCase)
|
||
.ToDictionary(group => group.Key, group => group.ToList(), StringComparer.OrdinalIgnoreCase);
|
||
|
||
foreach (var panel in panels)
|
||
{
|
||
panel.MountPatterns = map.TryGetValue(panel.Id, out var items)
|
||
? items
|
||
: new List<MountPatternInfo>();
|
||
}
|
||
}
|
||
|
||
private static List<MountPatternUnit> BuildUnits(
|
||
string panelId,
|
||
IReadOnlyList<HoleInfo> holes,
|
||
IReadOnlyList<HoleGroupInfo> holeGroups)
|
||
{
|
||
var units = new List<MountPatternUnit>();
|
||
var groupedHoleIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||
|
||
foreach (var group in holeGroups)
|
||
{
|
||
foreach (var holeId in group.HoleIds ?? Array.Empty<string>())
|
||
{
|
||
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<string>(),
|
||
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<string>(),
|
||
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<string>(),
|
||
Source = "single-hole",
|
||
});
|
||
}
|
||
|
||
return units;
|
||
}
|
||
|
||
private static List<List<MountPatternUnit>> BuildClusters(IReadOnlyList<MountPatternUnit> units)
|
||
{
|
||
var clusters = new List<List<MountPatternUnit>>();
|
||
var visited = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||
|
||
foreach (var unit in units)
|
||
{
|
||
if (!visited.Add(unit.Id))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
var cluster = new List<MountPatternUnit>();
|
||
var queue = new Queue<MountPatternUnit>();
|
||
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<MountPatternUnit> 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<string>();
|
||
var optionalHoleIds = Array.Empty<string>();
|
||
|
||
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<MountPatternUnit> 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<MountPatternUnit> cluster)
|
||
{
|
||
if (cluster.Count != 4)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
var distances = new List<double>();
|
||
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<MountPatternUnit> 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<MountPatternUnit> 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<string>? faces, string? fallbackFace = null)
|
||
{
|
||
var normalized = (faces ?? Array.Empty<string>())
|
||
.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<double> left, IReadOnlyList<double> 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<string>();
|
||
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<string>();
|
||
public double Confidence { get; set; }
|
||
public List<string> Warnings { get; set; } = new();
|
||
public string Source { get; set; } = "single-hole";
|
||
}
|
||
}
|