WuYongJie 5821d73291 初始提交:3D家具安装展示系统
包含:
- furniture-3d-system:Next.js 16 全栈应用(资产中心、产品管理、3D装配动画)
- autocad-plugin:AutoCAD 家具导出插件源码
- 资产中心改造:编辑弹窗化、删除功能、Toast反馈、自定义Select组件

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 14:00:19 +08:00

620 lines
23 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
namespace FurnitureExporter;
public class AssemblySemanticAnalyzer
{
private const double FaceGapToleranceMm = 3.0;
private const double RelaxedFaceGapToleranceMm = 10.0;
private const double FaceOverlapRatioMin = 0.25;
private const double OutsideMarginMm = 30.0;
private const double MinPreassembleDistanceMm = 80.0;
private const double HighConfidenceThreshold = 0.75;
private static readonly string[] MinFaces = { "left", "front", "bottom" };
private static readonly string[] MaxFaces = { "right", "back", "top" };
public AssemblyData Analyze(
IReadOnlyList<PanelInfo> panels,
IReadOnlyList<HoleInfo> holes,
IReadOnlyList<HoleGroupInfo> holeGroups,
IReadOnlyList<HardwareType> hardwareTypes)
{
_ = holes;
_ = holeGroups;
var result = new AssemblyData();
if (panels == null || panels.Count == 0)
{
return result;
}
var boxes = panels.ToDictionary(panel => panel.Id, BuildBox, StringComparer.OrdinalIgnoreCase);
var modelBox = MergeBoxes(boxes.Values);
var hardwareSignals = BuildHardwareSignals(hardwareTypes ?? Array.Empty<HardwareType>());
var pairRelations = new List<PairRelation>();
for (var i = 0; i < panels.Count; i++)
{
for (var j = i + 1; j < panels.Count; j++)
{
var relation = BuildPairRelation(
boxes[panels[i].Id],
boxes[panels[j].Id],
hardwareSignals.TryGetValue(PairKey(panels[i].Id, panels[j].Id), out var signal) ? signal : null);
if (relation != null)
{
pairRelations.Add(relation);
}
}
}
result.Connections = pairRelations
.Select(relation => new AssemblyConnectionInfo
{
SourcePanelId = relation.PanelAId,
TargetPanelId = relation.PanelBId,
SelfFace = relation.FaceFromA,
TargetFace = relation.FaceFromB,
ConnectionType = relation.ConnectionType,
Confidence = Math.Round(relation.Confidence, 4),
})
.OrderBy(item => item.SourcePanelId, StringComparer.OrdinalIgnoreCase)
.ThenBy(item => item.TargetPanelId, StringComparer.OrdinalIgnoreCase)
.ToList();
foreach (var panel in panels)
{
var semantic = BuildPanelAssembly(panel, boxes, modelBox, pairRelations);
result.PanelAssemblies.Add(semantic);
}
return result;
}
private static PanelAssemblyInfo BuildPanelAssembly(
PanelInfo panel,
IReadOnlyDictionary<string, PanelBox> boxes,
PanelBox modelBox,
IReadOnlyList<PairRelation> pairRelations)
{
var box = boxes[panel.Id];
var related = pairRelations
.Select(relation =>
{
if (string.Equals(relation.PanelAId, panel.Id, StringComparison.OrdinalIgnoreCase))
{
return new PanelRelationView(
relation.PanelBId,
relation.FaceFromA,
relation.ConnectionType,
relation.Confidence,
relation.Score,
relation.HasHardware);
}
if (string.Equals(relation.PanelBId, panel.Id, StringComparison.OrdinalIgnoreCase))
{
return new PanelRelationView(
relation.PanelAId,
relation.FaceFromB,
relation.ConnectionType,
relation.Confidence,
relation.Score,
relation.HasHardware);
}
return null;
})
.Where(item => item != null)
.OrderByDescending(item => item!.Score)
.ToList();
var targets = related
.Where(item => item!.Score >= 0.42)
.Select(item => item!.TargetPanelId)
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(4)
.ToList();
var dependsOn = related
.Where(item => item!.Score >= 0.55)
.Select(item => item!.TargetPanelId)
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(3)
.ToList();
var primary = related.FirstOrDefault();
var warnings = new List<string>();
var choice = ChooseDirection(panel, box, modelBox, primary?.SelfFace, primary?.HasHardware ?? false);
if (choice == null)
{
warnings.Add("未找到明确装配方向,建议在 Web 端人工补全。");
return new PanelAssemblyInfo
{
PanelId = panel.Id,
MountMode = "unknown",
AssemblyAxis = new[] { 0.0, 0.0, 0.0 },
AssemblyDirection = "unknown",
PreAssembleOffset = null,
TravelDistance = 0,
DependsOn = dependsOn,
PrimarySupportPanelId = primary?.TargetPanelId,
TargetPanelIds = targets,
Mates = BuildMateMap(related),
Confidence = 0.22,
Warnings = warnings,
AutoGenerated = true,
};
}
if (choice.Source == "relation" && !(primary?.HasHardware ?? false))
{
warnings.Add("当前方向主要依据板件配合面推导,建议结合孔位或五金关系复核。");
}
if (choice.Source == "fallback")
{
warnings.Add("当前方向主要依据板件类型和空间位置兜底,建议人工复核。");
}
if (!IsLikelyBoard(panel))
{
warnings.Add("当前实体尺寸更像块状件而不是标准板件,语义仅作兜底参考。");
}
if (targets.Count == 0)
{
warnings.Add("未找到明确目标板件,预装距离按整体模型边界兜底计算。");
}
var referenceBox = targets
.Select(id => boxes.TryGetValue(id, out var targetBox) ? targetBox : null)
.Where(item => item != null)
.Cast<PanelBox>()
.ToList();
var travel = CalculateTravel(box, referenceBox.Count > 0 ? MergeBoxes(referenceBox) : modelBox, choice.Direction);
var confidence = choice.Confidence;
if (targets.Count == 0) confidence = Math.Min(confidence, 0.58);
if (!IsLikelyBoard(panel)) confidence -= 0.14;
confidence = Clamp01(confidence);
if (confidence < HighConfidenceThreshold)
{
warnings.Add("当前语义置信度偏低,建议由产品人员在 Web 端确认后再用于正式步骤。");
}
return new PanelAssemblyInfo
{
PanelId = panel.Id,
MountMode = choice.MountMode,
AssemblyAxis = AxisVector(choice.Direction),
AssemblyDirection = choice.Direction,
PreAssembleOffset = OffsetVector(choice.Direction, travel),
TravelDistance = Math.Round(travel, 2),
DependsOn = dependsOn,
PrimarySupportPanelId = primary?.TargetPanelId,
TargetPanelIds = targets,
Mates = BuildMateMap(related),
Confidence = Math.Round(confidence, 4),
Warnings = warnings.Distinct(StringComparer.OrdinalIgnoreCase).ToList(),
AutoGenerated = true,
};
}
private static DirectionChoice? ChooseDirection(
PanelInfo panel,
PanelBox box,
PanelBox modelBox,
string? primaryFace,
bool hasHardware)
{
var normalizedType = (panel.PanelType ?? string.Empty).Trim().ToLowerInvariant();
if (normalizedType == "top_panel") return new DirectionChoice("top", "drop_in", 0.88, "fallback");
if (normalizedType == "bottom_panel") return new DirectionChoice("bottom", "drop_in", 0.84, "fallback");
if (normalizedType == "shelf") return new DirectionChoice("top", "drop_in", 0.78, "fallback");
if (normalizedType == "back_panel")
{
return new DirectionChoice(box.Center[1] >= modelBox.Center[1] ? "back" : "front", "slide_in", 0.76, "fallback");
}
if (!string.IsNullOrWhiteSpace(primaryFace))
{
var direction = OppositeFace(primaryFace!);
if (direction != "unknown")
{
return new DirectionChoice(
direction,
direction == "top" || direction == "bottom" ? "drop_in" : "slide_in",
hasHardware ? 0.9 : 0.68,
"relation");
}
}
if (normalizedType == "side_panel" || normalizedType == "vertical_divider")
{
return new DirectionChoice(box.Center[0] >= modelBox.Center[0] ? "right" : "left", "slide_in", 0.64, "fallback");
}
if (normalizedType == "horizontal_divider")
{
return new DirectionChoice(box.Center[1] >= modelBox.Center[1] ? "back" : "front", "slide_in", 0.62, "fallback");
}
return null;
}
private static PairRelation? BuildPairRelation(PanelBox boxA, PanelBox boxB, HardwareSignal? hardwareSignal)
{
var gapTolerance = hardwareSignal == null ? FaceGapToleranceMm : RelaxedFaceGapToleranceMm;
FaceCandidate? best = null;
for (var axis = 0; axis < 3; axis++)
{
var axisA = (axis + 1) % 3;
var axisB = (axis + 2) % 3;
var overlapA = Overlap(boxA.Min[axisA], boxA.Max[axisA], boxB.Min[axisA], boxB.Max[axisA]);
var overlapB = Overlap(boxA.Min[axisB], boxA.Max[axisB], boxB.Min[axisB], boxB.Max[axisB]);
var ratioA = overlapA / Math.Max(1.0, Math.Min(boxA.Size[axisA], boxB.Size[axisA]));
var ratioB = overlapB / Math.Max(1.0, Math.Min(boxA.Size[axisB], boxB.Size[axisB]));
if (ratioA < FaceOverlapRatioMin || ratioB < FaceOverlapRatioMin) continue;
var aBeforeB = boxA.Center[axis] <= boxB.Center[axis];
var gap = aBeforeB
? Math.Abs(boxA.Max[axis] - boxB.Min[axis])
: Math.Abs(boxB.Max[axis] - boxA.Min[axis]);
if (gap > gapTolerance) continue;
var faceFromA = aBeforeB ? MaxFaces[axis] : MinFaces[axis];
var faceFromB = OppositeFace(faceFromA);
var score = (1.0 - gap / Math.Max(gapTolerance, 0.001)) * 0.45 + ((ratioA + ratioB) / 2.0) * 0.55;
var candidate = new FaceCandidate(faceFromA, faceFromB, score);
if (best == null || candidate.Score > best.Value.Score)
{
best = candidate;
}
}
if (best != null)
{
var confidence = 0.54 + best.Value.Score * 0.22 + (hardwareSignal?.Confidence ?? 0) * 0.12;
return new PairRelation(
boxA.Panel.Id,
boxB.Panel.Id,
best.Value.FaceFromA,
best.Value.FaceFromB,
hardwareSignal?.ConnectionType ?? "adjacent_face",
Clamp01(confidence),
best.Value.Score + (hardwareSignal?.Confidence ?? 0) * 0.2,
hardwareSignal != null);
}
if (hardwareSignal == null)
{
return null;
}
var delta = new[]
{
boxB.Center[0] - boxA.Center[0],
boxB.Center[1] - boxA.Center[1],
boxB.Center[2] - boxA.Center[2],
};
var dominantAxis = 0;
if (Math.Abs(delta[1]) > Math.Abs(delta[dominantAxis])) dominantAxis = 1;
if (Math.Abs(delta[2]) > Math.Abs(delta[dominantAxis])) dominantAxis = 2;
var derivedFace = delta[dominantAxis] >= 0 ? MaxFaces[dominantAxis] : MinFaces[dominantAxis];
return new PairRelation(
boxA.Panel.Id,
boxB.Panel.Id,
derivedFace,
OppositeFace(derivedFace),
hardwareSignal.ConnectionType,
Clamp01(0.48 + hardwareSignal.Confidence * 0.24),
0.5 + hardwareSignal.Confidence * 0.3,
true);
}
private static Dictionary<string, HardwareSignal> BuildHardwareSignals(IReadOnlyList<HardwareType> hardwareTypes)
{
var signals = new Dictionary<string, HardwareSignal>(StringComparer.OrdinalIgnoreCase);
foreach (var hardwareType in hardwareTypes)
{
foreach (var instance in hardwareType.Instances ?? new List<HardwareInstance>())
{
var primary = instance.PrimaryPanelId?.Trim();
var secondary = instance.SecondaryPanelId?.Trim();
if (string.IsNullOrWhiteSpace(primary) ||
string.IsNullOrWhiteSpace(secondary) ||
string.Equals(primary, secondary, StringComparison.OrdinalIgnoreCase))
{
continue;
}
var key = PairKey(primary!, secondary!);
if (!signals.TryGetValue(key, out var signal))
{
signal = new HardwareSignal();
signals[key] = signal;
}
signal.Count++;
signal.ConfidenceSum += Clamp01(instance.BindingConfidence ?? 0.65);
if (!string.IsNullOrWhiteSpace(instance.BoundHoleGroupId)) signal.ConnectionType = "hole_group_hardware";
else if ((instance.BoundHoleIds?.Length ?? 0) > 0) signal.ConnectionType = "hole_hardware";
else
{
var bindingMode = instance.BindingMode;
if (bindingMode != null &&
bindingMode.Length > 0 &&
bindingMode.StartsWith("panel_", StringComparison.OrdinalIgnoreCase))
{
signal.ConnectionType = "panel_hardware";
}
}
}
}
return signals;
}
private static PanelAssemblyMateMap BuildMateMap(IEnumerable<PanelRelationView?> related)
{
var map = new PanelAssemblyMateMap();
foreach (var item in related.Where(static entry => entry is { Score: >= 0.42 }).Take(6))
{
AddMate(map, item!.SelfFace, item.TargetPanelId);
}
return map;
}
private static void AddMate(PanelAssemblyMateMap map, string face, string targetPanelId)
{
List<string>? bucket = face switch
{
"left" => map.Left ??= new List<string>(),
"right" => map.Right ??= new List<string>(),
"front" => map.Front ??= new List<string>(),
"back" => map.Back ??= new List<string>(),
"top" => map.Top ??= new List<string>(),
"bottom" => map.Bottom ??= new List<string>(),
_ => null,
};
if (bucket == null) return;
if (!bucket.Contains(targetPanelId, StringComparer.OrdinalIgnoreCase)) bucket.Add(targetPanelId);
}
private static double CalculateTravel(PanelBox panelBox, PanelBox referenceBox, string direction)
{
double travel = direction switch
{
"left" => panelBox.Max[0] - referenceBox.Min[0] + OutsideMarginMm,
"right" => referenceBox.Max[0] - panelBox.Min[0] + OutsideMarginMm,
"front" => panelBox.Max[1] - referenceBox.Min[1] + OutsideMarginMm,
"back" => referenceBox.Max[1] - panelBox.Min[1] + OutsideMarginMm,
"bottom" => panelBox.Max[2] - referenceBox.Min[2] + OutsideMarginMm,
"top" => referenceBox.Max[2] - panelBox.Min[2] + OutsideMarginMm,
_ => 0,
};
return Math.Round(Math.Max(MinPreassembleDistanceMm, travel), 2);
}
private static double[] AxisVector(string direction)
{
return direction 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, 0.0 },
};
}
private static double[]? OffsetVector(string direction, double travel)
{
var axis = AxisVector(direction);
if (axis[0] == 0 && axis[1] == 0 && axis[2] == 0) return null;
return new[]
{
Math.Round(axis[0] * travel, 2),
Math.Round(axis[1] * travel, 2),
Math.Round(axis[2] * travel, 2),
};
}
private static bool IsLikelyBoard(PanelInfo panel)
{
if (panel.Size == null || panel.Size.Length < 3) return false;
var thickness = Math.Max(panel.Size[2], 1.0);
return thickness <= 50 && panel.Size[0] / thickness >= 2.5 && panel.Size[1] / thickness >= 1.5;
}
private static PanelBox BuildBox(PanelInfo panel)
{
if (panel.BoundingBox != null &&
panel.BoundingBox.Min?.Length == 3 &&
panel.BoundingBox.Max?.Length == 3)
{
return new PanelBox(panel, panel.BoundingBox.Min, panel.BoundingBox.Max);
}
var center = panel.Position?.Length == 3 ? panel.Position : new[] { 0.0, 0.0, 0.0 };
var size = panel.Size?.Length == 3 ? panel.Size : new[] { 0.0, 0.0, 0.0 };
var normal = panel.Normal?.Length == 3 ? panel.Normal : new[] { 0.0, 0.0, 1.0 };
var thicknessAxis = Math.Abs(normal[0]) >= Math.Abs(normal[1]) && Math.Abs(normal[0]) >= Math.Abs(normal[2]) ? 0 :
Math.Abs(normal[1]) >= Math.Abs(normal[2]) ? 1 : 2;
var axes = Enumerable.Range(0, 3).Where(axis => axis != thicknessAxis).ToArray();
var axisSizes = new double[3];
axisSizes[thicknessAxis] = size[2];
axisSizes[axes[0]] = size[0];
axisSizes[axes[1]] = size[1];
return new PanelBox(
panel,
new[]
{
center[0] - axisSizes[0] / 2.0,
center[1] - axisSizes[1] / 2.0,
center[2] - axisSizes[2] / 2.0,
},
new[]
{
center[0] + axisSizes[0] / 2.0,
center[1] + axisSizes[1] / 2.0,
center[2] + axisSizes[2] / 2.0,
});
}
private static PanelBox MergeBoxes(IEnumerable<PanelBox> boxes)
{
var min = new[] { double.MaxValue, double.MaxValue, double.MaxValue };
var max = new[] { double.MinValue, double.MinValue, double.MinValue };
foreach (var box in boxes)
{
for (var axis = 0; axis < 3; axis++)
{
min[axis] = Math.Min(min[axis], box.Min[axis]);
max[axis] = Math.Max(max[axis], box.Max[axis]);
}
}
return new PanelBox(new PanelInfo { Id = "__merged__" }, min, max);
}
private static string PairKey(string panelIdA, string panelIdB)
{
return string.Compare(panelIdA, panelIdB, StringComparison.OrdinalIgnoreCase) <= 0
? $"{panelIdA}::{panelIdB}"
: $"{panelIdB}::{panelIdA}";
}
private static string OppositeFace(string face)
{
return face switch
{
"left" => "right",
"right" => "left",
"front" => "back",
"back" => "front",
"top" => "bottom",
"bottom" => "top",
_ => "unknown",
};
}
private static double Overlap(double minA, double maxA, double minB, double maxB)
{
return Math.Max(0.0, Math.Min(maxA, maxB) - Math.Max(minA, minB));
}
private static double Clamp01(double value)
{
if (value < 0) return 0;
if (value > 1) return 1;
return value;
}
private readonly struct FaceCandidate
{
public FaceCandidate(string faceFromA, string faceFromB, double score)
{
FaceFromA = faceFromA;
FaceFromB = faceFromB;
Score = score;
}
public string FaceFromA { get; }
public string FaceFromB { get; }
public double Score { get; }
}
private sealed class PairRelation
{
public PairRelation(string panelAId, string panelBId, string faceFromA, string faceFromB, string connectionType, double confidence, double score, bool hasHardware)
{
PanelAId = panelAId;
PanelBId = panelBId;
FaceFromA = faceFromA;
FaceFromB = faceFromB;
ConnectionType = connectionType;
Confidence = confidence;
Score = score;
HasHardware = hasHardware;
}
public string PanelAId { get; }
public string PanelBId { get; }
public string FaceFromA { get; }
public string FaceFromB { get; }
public string ConnectionType { get; }
public double Confidence { get; }
public double Score { get; }
public bool HasHardware { get; }
}
private sealed class PanelRelationView
{
public PanelRelationView(string targetPanelId, string selfFace, string connectionType, double confidence, double score, bool hasHardware)
{
TargetPanelId = targetPanelId;
SelfFace = selfFace;
ConnectionType = connectionType;
Confidence = confidence;
Score = score;
HasHardware = hasHardware;
}
public string TargetPanelId { get; }
public string SelfFace { get; }
public string ConnectionType { get; }
public double Confidence { get; }
public double Score { get; }
public bool HasHardware { get; }
}
private sealed class PanelBox
{
public PanelBox(PanelInfo panel, IReadOnlyList<double> min, IReadOnlyList<double> max)
{
Panel = panel;
Min = min.ToArray();
Max = max.ToArray();
Size = new[] { Math.Abs(Max[0] - Min[0]), Math.Abs(Max[1] - Min[1]), Math.Abs(Max[2] - Min[2]) };
Center = new[] { (Min[0] + Max[0]) / 2.0, (Min[1] + Max[1]) / 2.0, (Min[2] + Max[2]) / 2.0 };
}
public PanelInfo Panel { get; }
public double[] Min { get; }
public double[] Max { get; }
public double[] Size { get; }
public double[] Center { get; }
}
private sealed class DirectionChoice
{
public DirectionChoice(string direction, string mountMode, double confidence, string source)
{
Direction = direction;
MountMode = mountMode;
Confidence = confidence;
Source = source;
}
public string Direction { get; }
public string MountMode { get; }
public double Confidence { get; }
public string Source { get; }
}
private sealed class HardwareSignal
{
public int Count { get; set; }
public double ConfidenceSum { get; set; }
public string ConnectionType { get; set; } = "panel_hardware";
public double Confidence => Count <= 0 ? 0 : ConfidenceSum / Count;
}
}