827 lines
38 KiB
C#
827 lines
38 KiB
C#
using SolidWorks.Interop.sldworks;
|
|
|
|
static class AssemblyModelProcessing
|
|
{
|
|
public static List<ComponentHighlightImageRequest> BuildComponentHighlightImageRequests(SectionBrepReport report)
|
|
{
|
|
var componentsById = report.AssemblyComponents
|
|
.ToDictionary(component => component.Id, StringComparer.OrdinalIgnoreCase);
|
|
|
|
var designTargets = report.PartImagePlan
|
|
.Where(item => !string.IsNullOrWhiteSpace(item.ComponentId))
|
|
.Where(item => IsTopLevelAssemblyComponent(item.InstanceName))
|
|
.GroupBy(item => item.ComponentId, StringComparer.OrdinalIgnoreCase)
|
|
.Select(group => group.First())
|
|
.Select(item => new ComponentHighlightImageRequest
|
|
{
|
|
PlanId = item.AssemblyContextImagePlanId,
|
|
ComponentId = item.ComponentId,
|
|
InstanceName = item.InstanceName,
|
|
DisplayName = item.DisplayName,
|
|
ComponentPath = item.ComponentPath,
|
|
Views = ["best_oblique"],
|
|
PreferredDirectionMm = componentsById.TryGetValue(item.ComponentId, out var component)
|
|
? ChooseComponentHighlightDirection(component, report.AssemblyComponents)
|
|
: [],
|
|
ShowComponents =
|
|
[
|
|
BuildComponentImageRef(
|
|
componentsById.GetValueOrDefault(item.ComponentId),
|
|
item.InstanceName,
|
|
item.DisplayName,
|
|
item.ComponentPath)
|
|
]
|
|
})
|
|
.ToList();
|
|
|
|
return designTargets;
|
|
}
|
|
|
|
static bool IsTopLevelAssemblyComponent(string instanceName)
|
|
{
|
|
return !string.IsNullOrWhiteSpace(instanceName) &&
|
|
!instanceName.Contains("/", StringComparison.Ordinal) &&
|
|
!instanceName.Contains("\\", StringComparison.Ordinal);
|
|
}
|
|
|
|
public static List<ComponentContextImageRequest> BuildComponentContextImageRequests(SectionBrepReport report)
|
|
{
|
|
var componentsById = report.AssemblyComponents
|
|
.ToDictionary(component => component.Id, StringComparer.OrdinalIgnoreCase);
|
|
|
|
return report.ComponentContextImagePlan
|
|
.Where(item => ShouldExportComponentContext(item, report.ExternalInterfaceRoots))
|
|
.Where(item => item.ShowComponents.Count > 0)
|
|
.Select(item =>
|
|
{
|
|
var showComponents = item.ShowComponents
|
|
.Select(component => BuildComponentImageRef(component, componentsById))
|
|
.ToList();
|
|
var preserveComponents = item.BoundaryComponents
|
|
.Select(component => BuildComponentImageRef(component, componentsById))
|
|
.ToList();
|
|
var protectedIds = showComponents
|
|
.Concat(preserveComponents)
|
|
.Select(component => component.ComponentId)
|
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
var hideComponents = item.MemberOcclusionAnalyses
|
|
.SelectMany(analysis => analysis.Directions)
|
|
.Where(direction => direction.Occluded)
|
|
.SelectMany(direction => direction.FirstHitComponents)
|
|
.Where(blocker => !protectedIds.Contains(blocker.ComponentId))
|
|
.DistinctBy(blocker => $"{blocker.InstanceName}::{blocker.ComponentPath}", StringComparer.OrdinalIgnoreCase)
|
|
.Select(blocker => new ComponentImageRef
|
|
{
|
|
ComponentId = blocker.ComponentId,
|
|
InstanceName = blocker.InstanceName,
|
|
DisplayName = blocker.DisplayName,
|
|
ComponentPath = blocker.ComponentPath,
|
|
BBoxMm = componentsById.TryGetValue(blocker.ComponentId, out var blockerSummary)
|
|
? blockerSummary.BBoxMm
|
|
: []
|
|
})
|
|
.ToList();
|
|
|
|
return new ComponentContextImageRequest
|
|
{
|
|
PlanId = item.Id,
|
|
GroupId = item.GroupId,
|
|
TargetComponentId = item.TargetComponentId,
|
|
TargetInstanceName = item.TargetInstanceName,
|
|
TargetDisplayName = item.TargetDisplayName,
|
|
TargetComponentPath = item.TargetComponentPath,
|
|
Views = ["best_oblique"],
|
|
PreferredDirectionMm = componentsById.TryGetValue(item.TargetComponentId, out var component)
|
|
? ChooseComponentHighlightDirection(component, report.AssemblyComponents)
|
|
: [],
|
|
ShowComponents = showComponents,
|
|
PreserveComponents = preserveComponents,
|
|
HideComponents = hideComponents
|
|
};
|
|
})
|
|
.ToList();
|
|
}
|
|
|
|
static bool ShouldExportComponentContext(ComponentContextImagePlanItem item, IReadOnlyList<string> externalInterfaceRoots)
|
|
{
|
|
if (externalInterfaceRoots.Count == 0)
|
|
return true;
|
|
|
|
// Purchased-component hints are semantic hints, not a switch to disable
|
|
// all component context images. But ordinary component-context images
|
|
// should not expand the inside of a user-specified purchased module.
|
|
// Those internal contacts are represented by physical-interface images
|
|
// when they cross the purchased-module boundary.
|
|
return !IsExternalInterfaceRootDescendant(item.TargetInstanceName, item.TargetComponentPath, externalInterfaceRoots);
|
|
}
|
|
|
|
static bool IsExternalInterfaceRootDescendant(string instanceName, string path, IReadOnlyList<string> roots)
|
|
{
|
|
var name = NormalizeRootMatchText(instanceName);
|
|
var strippedName = NormalizeRootMatchText(StripInstanceSuffix(instanceName));
|
|
var file = NormalizeRootMatchText(Path.GetFileNameWithoutExtension(path));
|
|
foreach (var root in roots.Select(NormalizeRootMatchText).Where(root => root.Length > 0))
|
|
{
|
|
var exactRoot =
|
|
name.Equals(root, StringComparison.OrdinalIgnoreCase) ||
|
|
strippedName.Equals(root, StringComparison.OrdinalIgnoreCase) ||
|
|
file.Equals(root, StringComparison.OrdinalIgnoreCase);
|
|
if (exactRoot)
|
|
return false;
|
|
|
|
if (name.StartsWith(root + "-", StringComparison.OrdinalIgnoreCase) ||
|
|
name.StartsWith(root + "/", StringComparison.OrdinalIgnoreCase) ||
|
|
strippedName.StartsWith(root + "-", StringComparison.OrdinalIgnoreCase) ||
|
|
strippedName.StartsWith(root + "/", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
public static List<InterfaceHighlightImageRequest> BuildInterfaceHighlightImageRequests(
|
|
SectionBrepReport report,
|
|
int maxRequests = 30,
|
|
bool compactViews = false)
|
|
{
|
|
if (!report.DocumentKind.Equals("assembly", StringComparison.OrdinalIgnoreCase) ||
|
|
report.AssemblyFaceContacts.Count == 0)
|
|
{
|
|
return [];
|
|
}
|
|
|
|
var components = report.AssemblyComponents;
|
|
var groups = BuildPhysicalInterfaceGroups(report.AssemblyFaceContacts, report.ExternalInterfaceRoots)
|
|
.OrderByDescending(group => group.AnalysisPriority)
|
|
.ThenByDescending(group => group.Contacts.Max(contact => contact.Confidence))
|
|
.Take(Math.Max(0, maxRequests))
|
|
.ToList();
|
|
var requests = new List<InterfaceHighlightImageRequest>();
|
|
|
|
foreach (var group in groups)
|
|
{
|
|
var contact = group.Contacts[0];
|
|
var componentA = FindComponentSummary(components, contact.ComponentA, contact.ComponentPathA);
|
|
var componentB = FindComponentSummary(components, contact.ComponentB, contact.ComponentPathB);
|
|
var refA = BuildComponentImageRef(componentA, contact.ComponentA, contact.ComponentDisplayNameA, contact.ComponentPathA);
|
|
var refB = BuildComponentImageRef(componentB, contact.ComponentB, contact.ComponentDisplayNameB, contact.ComponentPathB);
|
|
var axis = RepresentativeAxis(group.Contacts);
|
|
var normal = RepresentativeNormal(group.Contacts);
|
|
var interfaceBox = UnionBoxes(group.Contacts.SelectMany(item => new[] { item.BBoxA, item.BBoxB }));
|
|
var isCylindrical = contact.ContactKind.Contains("cylindrical", StringComparison.OrdinalIgnoreCase);
|
|
var verificationStatuses = group.Contacts
|
|
.Select(item => item.VerificationStatus)
|
|
.Where(status => !string.IsNullOrWhiteSpace(status))
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.ToList();
|
|
|
|
requests.Add(new InterfaceHighlightImageRequest
|
|
{
|
|
PlanId = $"physical_interface_highlight_{SanitizeId(group.Id)}",
|
|
InterfaceId = group.Id,
|
|
ContactKind = contact.ContactKind,
|
|
MateRole = contact.MateRole,
|
|
ComponentA = refA,
|
|
ComponentB = refB,
|
|
FaceARef = group.FaceARefs[0],
|
|
FaceBRef = group.FaceBRefs[0],
|
|
FaceAIndex = group.FaceAIndices[0],
|
|
FaceBIndex = group.FaceBIndices[0],
|
|
FaceARefs = group.FaceARefs,
|
|
FaceBRefs = group.FaceBRefs,
|
|
FaceAIndices = group.FaceAIndices,
|
|
FaceBIndices = group.FaceBIndices,
|
|
RuntimeFaces = group.RuntimeFaces,
|
|
SourceContactIds = group.Contacts.Select(item => item.Id).Distinct(StringComparer.OrdinalIgnoreCase).ToList(),
|
|
Views = compactViews
|
|
? ["assembled_oblique"]
|
|
: isCylindrical
|
|
? ["assembled_oblique", "axis_end", "axis_side"]
|
|
: ["assembled_oblique", "normal_view"],
|
|
PreferredDirectionMm = ChooseInterfaceHighlightDirection(contact, components),
|
|
InterfaceAxisMm = axis,
|
|
InterfaceNormalMm = normal,
|
|
InterfaceCenterMm = AverageCenters(group.Contacts),
|
|
InterfaceBBoxMm = interfaceBox,
|
|
AnalysisPriority = group.AnalysisPriority,
|
|
ConfidenceTier = group.ConfidenceTier,
|
|
EvidencePurpose = "physical_interface_function_analysis",
|
|
TrimmedPatchVerified = group.Contacts.All(item => item.TrimmedPatchVerified),
|
|
VerificationStatus = verificationStatuses.Count == 1
|
|
? verificationStatuses[0]
|
|
: "mixed_verified_interface",
|
|
VerificationMethods = group.Contacts
|
|
.Select(item => item.VerificationMethod)
|
|
.Where(method => !string.IsNullOrWhiteSpace(method))
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.ToList(),
|
|
VerificationSampleHits = group.Contacts.Sum(item => item.VerificationSampleHits),
|
|
VerificationMinDistanceMm = group.Contacts.Min(item => item.VerificationMinDistanceMm),
|
|
DisplayMode = "actual_assembly_position_with_native_selected_interface_faces",
|
|
ActualAssemblyPosition = true,
|
|
NotActualClearance = true,
|
|
ShowComponents = [refA, refB],
|
|
PreserveComponents = []
|
|
});
|
|
}
|
|
|
|
return requests;
|
|
}
|
|
|
|
static List<PhysicalInterfaceGroup> BuildPhysicalInterfaceGroups(
|
|
IReadOnlyList<AssemblyFaceContactEvidence> contacts,
|
|
IReadOnlyList<string> externalInterfaceRoots)
|
|
{
|
|
var planarProfilePairKeys = BuildPlanarProfilePairKeys(contacts);
|
|
var groups = new List<PhysicalInterfaceGroup>();
|
|
foreach (var contact in contacts
|
|
.Where(contact => contact.FaceA > 0 && contact.FaceB > 0)
|
|
.Where(IsConfirmedInterfaceContact)
|
|
.Where(contact => IsExternalPurchasedInterfaceContact(contact, externalInterfaceRoots))
|
|
.Where(contact => ShouldExportFitLikeInterface(contact, planarProfilePairKeys)))
|
|
{
|
|
var key = PhysicalInterfaceKey(contact);
|
|
var group = groups.FirstOrDefault(candidate =>
|
|
candidate.BaseKey.Equals(key, StringComparison.OrdinalIgnoreCase) &&
|
|
candidate.Contacts.Any(existing => SamePhysicalInterfaceSegment(existing, contact)));
|
|
if (group == null)
|
|
{
|
|
group = new PhysicalInterfaceGroup
|
|
{
|
|
BaseKey = key,
|
|
Id = $"physical_interface_{groups.Count + 1:000}_{ShortInterfaceToken(contact.ComponentFileNameA, contact.ComponentA)}__{ShortInterfaceToken(contact.ComponentFileNameB, contact.ComponentB)}"
|
|
};
|
|
groups.Add(group);
|
|
}
|
|
|
|
// Keep every group oriented like its first contact so face-side identity remains stable.
|
|
var first = group.Contacts.FirstOrDefault();
|
|
var sameOrientation = first == null || SameComponentIdentity(first.ComponentA, first.ComponentPathA, contact.ComponentA, contact.ComponentPathA);
|
|
group.Contacts.Add(contact);
|
|
if (sameOrientation)
|
|
{
|
|
AddFace(group.FaceARefs, group.FaceAIndices, contact.ComponentA, contact.FaceA);
|
|
AddFace(group.FaceBRefs, group.FaceBIndices, contact.ComponentB, contact.FaceB);
|
|
AddRuntimeFace(group.RuntimeFaces, contact.RuntimeFaceA);
|
|
AddRuntimeFace(group.RuntimeFaces, contact.RuntimeFaceB);
|
|
}
|
|
else
|
|
{
|
|
AddFace(group.FaceARefs, group.FaceAIndices, contact.ComponentB, contact.FaceB);
|
|
AddFace(group.FaceBRefs, group.FaceBIndices, contact.ComponentA, contact.FaceA);
|
|
AddRuntimeFace(group.RuntimeFaces, contact.RuntimeFaceB);
|
|
AddRuntimeFace(group.RuntimeFaces, contact.RuntimeFaceA);
|
|
}
|
|
}
|
|
|
|
foreach (var group in groups)
|
|
{
|
|
var text = string.Join(" ", group.Contacts.Select(contact =>
|
|
$"{contact.ContactKind} {contact.MateRole} {contact.ComponentCategoryA} {contact.ComponentCategoryB} {contact.ComponentDisplayNameA} {contact.ComponentDisplayNameB}"));
|
|
var containsKnownStandardPart = ContainsAny(text, "bearing", "轴承", "bolt", "screw", "nut", "pin", "key", "螺", "销", "键");
|
|
var geometryConfidence = group.Contacts.Max(contact => contact.Confidence);
|
|
group.AnalysisPriority = (containsKnownStandardPart ? 100 : 0)
|
|
+ (group.Contacts.Any(contact => contact.ContactKind.Contains("cylindrical", StringComparison.OrdinalIgnoreCase)) ? 30 : 10)
|
|
+ geometryConfidence * 20
|
|
+ Math.Min(10, group.Contacts.Count);
|
|
group.ConfidenceTier = containsKnownStandardPart && geometryConfidence >= 0.75
|
|
? "anchor"
|
|
: geometryConfidence >= 0.75 ? "high" : geometryConfidence >= 0.45 ? "medium" : "low";
|
|
}
|
|
|
|
return groups;
|
|
}
|
|
|
|
// Physical-interface images are boundary evidence only:
|
|
// exactly one side must be a purchased/standard component and the other
|
|
// side must be a top-level external non-purchased component. Internal
|
|
// purchased-to-purchased or purchased-to-internal contacts remain in the
|
|
// B-rep contact report but are not exported as physical-interface images.
|
|
static bool IsExternalPurchasedInterfaceContact(AssemblyFaceContactEvidence contact, IReadOnlyList<string> externalInterfaceRoots)
|
|
{
|
|
if (externalInterfaceRoots.Count > 0)
|
|
{
|
|
var aIsRoot = IsExternalInterfaceRootMember(contact.ComponentA, contact.ComponentPathA, externalInterfaceRoots);
|
|
var bIsRoot = IsExternalInterfaceRootMember(contact.ComponentB, contact.ComponentPathB, externalInterfaceRoots);
|
|
return aIsRoot ^ bIsRoot;
|
|
}
|
|
|
|
var purchasedA = IsPurchasedInterfaceComponent(contact.ComponentCategoryA, contact.ComponentA, contact.ComponentPathA);
|
|
var purchasedB = IsPurchasedInterfaceComponent(contact.ComponentCategoryB, contact.ComponentB, contact.ComponentPathB);
|
|
if (purchasedA == purchasedB)
|
|
return false;
|
|
|
|
var externalOther = purchasedA
|
|
? contact.ComponentB
|
|
: contact.ComponentA;
|
|
var otherCategory = purchasedA
|
|
? contact.ComponentCategoryB
|
|
: contact.ComponentCategoryA;
|
|
var otherPath = purchasedA
|
|
? contact.ComponentPathB
|
|
: contact.ComponentPathA;
|
|
return !IsPurchasedInterfaceComponent(otherCategory, externalOther, otherPath) &&
|
|
IsTopLevelAssemblyComponent(externalOther);
|
|
}
|
|
|
|
static bool IsExternalInterfaceRootMember(string instanceName, string path, IReadOnlyList<string> roots)
|
|
{
|
|
var name = NormalizeRootMatchText(instanceName);
|
|
var strippedName = NormalizeRootMatchText(StripInstanceSuffix(instanceName));
|
|
var file = NormalizeRootMatchText(Path.GetFileNameWithoutExtension(path));
|
|
foreach (var root in roots.Select(NormalizeRootMatchText).Where(root => root.Length > 0))
|
|
{
|
|
if (name.Equals(root, StringComparison.OrdinalIgnoreCase) ||
|
|
strippedName.Equals(root, StringComparison.OrdinalIgnoreCase) ||
|
|
file.Equals(root, StringComparison.OrdinalIgnoreCase) ||
|
|
name.StartsWith(root + "-", StringComparison.OrdinalIgnoreCase) ||
|
|
name.StartsWith(root + "/", StringComparison.OrdinalIgnoreCase) ||
|
|
strippedName.StartsWith(root + "-", StringComparison.OrdinalIgnoreCase) ||
|
|
strippedName.StartsWith(root + "/", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
static string StripInstanceSuffix(string value) =>
|
|
System.Text.RegularExpressions.Regex.Replace(value ?? "", @"-\d+(?=/|$)", "");
|
|
|
|
static string NormalizeRootMatchText(string value) =>
|
|
(value ?? "").Trim().Replace('\\', '/');
|
|
|
|
static bool IsPurchasedInterfaceComponent(string category, string instanceName, string path)
|
|
{
|
|
if (category.Equals("bearing", StringComparison.OrdinalIgnoreCase) ||
|
|
category.Equals("purchased_motor", StringComparison.OrdinalIgnoreCase) ||
|
|
category.Equals("purchased_reducer", StringComparison.OrdinalIgnoreCase) ||
|
|
category.Equals("purchased_motor_reducer_assembly", StringComparison.OrdinalIgnoreCase) ||
|
|
category.Equals("user_purchased_component", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
var text = $"{category} {instanceName} {path}".ToLowerInvariant();
|
|
if (LooksLikeDesignedConnectorOrHousing(text))
|
|
return false;
|
|
|
|
return ContainsAny(
|
|
text,
|
|
"gb", "t70", "t276", "bearing", "bolt", "screw",
|
|
"csg", "shg", "mhc", "ec60", "purchased_motor", "purchased_reducer", "gearbox",
|
|
"motor", "servo", "reducer", "gear motor",
|
|
"\u8f74\u627f", "\u87ba\u9489", "\u87ba\u6813", "\u7535\u673a", "\u9a6c\u8fbe", "\u4f3a\u670d", "\u51cf\u901f\u5668", "\u51cf\u901f\u673a");
|
|
}
|
|
|
|
static bool LooksLikeDesignedConnectorOrHousing(string text)
|
|
{
|
|
return ContainsAny(
|
|
text,
|
|
"\u8fde\u63a5\u4ef6", "\u6cd5\u5170", "\u5916\u58f3", "\u58f3\u4f53", "\u652f\u67b6", "\u5ea7",
|
|
"connector", "adapter", "bracket", "flange", "housing", "mount");
|
|
}
|
|
|
|
static bool IsConfirmedInterfaceContact(AssemblyFaceContactEvidence contact) =>
|
|
contact.TrimmedPatchVerified &&
|
|
(contact.VerificationStatus.Equals("trimmed_interface_confirmed", StringComparison.OrdinalIgnoreCase) ||
|
|
contact.VerificationStatus.Equals("trimmed_cylindrical_domain_confirmed", StringComparison.OrdinalIgnoreCase) ||
|
|
contact.VerificationStatus.Equals("analytic_cylindrical_domain_confirmed", StringComparison.OrdinalIgnoreCase));
|
|
|
|
static string ShortInterfaceToken(string fileName, string componentName)
|
|
{
|
|
var token = SanitizeId(FirstNonEmpty(Path.GetFileNameWithoutExtension(fileName), componentName, "component"));
|
|
return token.Length <= 24 ? token : token[..24];
|
|
}
|
|
|
|
static bool SamePhysicalInterfaceSegment(AssemblyFaceContactEvidence a, AssemblyFaceContactEvidence b)
|
|
{
|
|
if (!a.ContactKind.Contains("cylindrical", StringComparison.OrdinalIgnoreCase) ||
|
|
!b.ContactKind.Contains("cylindrical", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
var axis = a.AxisA.Length >= 3 ? Vec.Normalize(a.AxisA) : Vec.Normalize(a.AxisB);
|
|
if (axis.Length < 3)
|
|
return true;
|
|
var intervalA = ContactAxialInterval(a, axis);
|
|
var intervalB = ContactAxialInterval(b, axis);
|
|
if (!intervalA.HasValue || !intervalB.HasValue)
|
|
return true;
|
|
var radius = new[] { a.RadiusAmm, a.RadiusBmm, b.RadiusAmm, b.RadiusBmm }.Where(value => value > 0).DefaultIfEmpty(1).Average();
|
|
var allowedGap = Math.Max(0.5, radius * 0.03);
|
|
return intervalA.Value.Min <= intervalB.Value.Max + allowedGap &&
|
|
intervalB.Value.Min <= intervalA.Value.Max + allowedGap;
|
|
}
|
|
|
|
static (double Min, double Max)? ContactAxialInterval(AssemblyFaceContactEvidence contact, double[] axis)
|
|
{
|
|
var boxes = new[] { contact.BBoxA, contact.BBoxB }.Where(box => box.Length >= 6).ToList();
|
|
if (boxes.Count == 0)
|
|
return null;
|
|
var values = boxes.SelectMany(BoxCorners).Select(point => Vec.Dot(point, axis)).ToList();
|
|
return (values.Min(), values.Max());
|
|
}
|
|
|
|
static string PhysicalInterfaceKey(AssemblyFaceContactEvidence contact)
|
|
{
|
|
var pair = ComponentPairKey(
|
|
FirstNonEmpty(contact.ComponentPathA, contact.ComponentA),
|
|
FirstNonEmpty(contact.ComponentPathB, contact.ComponentB));
|
|
if (!contact.ContactKind.Contains("cylindrical", StringComparison.OrdinalIgnoreCase))
|
|
return $"{pair}|planar_profile";
|
|
|
|
var axis = contact.AxisA.Length >= 3 ? Vec.Normalize(contact.AxisA) : Vec.Normalize(contact.AxisB);
|
|
axis = CanonicalDirection(axis);
|
|
var radius = new[] { contact.RadiusAmm, contact.RadiusBmm }.Where(value => value > 0).DefaultIfEmpty(0).Average();
|
|
var center = AverageVectors(contact.CenterA, contact.CenterB);
|
|
var axisOffset = axis.Length >= 3 && center.Length >= 3
|
|
? Vec.Sub(center, Vec.Mul(axis, Vec.Dot(center, axis)))
|
|
: [];
|
|
return string.Join("|", new[]
|
|
{
|
|
pair,
|
|
"cylindrical",
|
|
VectorKey(axis, 0.02),
|
|
VectorKey(axisOffset, Math.Max(0.1, radius * 0.01)),
|
|
$"r{Math.Round(radius, 2):0.00}"
|
|
});
|
|
}
|
|
|
|
static void AddFace(List<string> refs, List<int> indices, string component, int faceIndex)
|
|
{
|
|
if (faceIndex <= 0)
|
|
return;
|
|
var faceRef = $"{component}:face#{faceIndex}";
|
|
if (refs.Contains(faceRef, StringComparer.OrdinalIgnoreCase))
|
|
return;
|
|
refs.Add(faceRef);
|
|
indices.Add(faceIndex);
|
|
}
|
|
|
|
static void AddRuntimeFace(List<Face2> faces, Face2? face)
|
|
{
|
|
if (face != null && !faces.Any(existing => ReferenceEquals(existing, face)))
|
|
faces.Add(face);
|
|
}
|
|
|
|
static bool SameComponentIdentity(string nameA, string pathA, string nameB, string pathB) =>
|
|
!string.IsNullOrWhiteSpace(pathA) && !string.IsNullOrWhiteSpace(pathB)
|
|
? pathA.Equals(pathB, StringComparison.OrdinalIgnoreCase)
|
|
: nameA.Equals(nameB, StringComparison.OrdinalIgnoreCase);
|
|
|
|
static double[] RepresentativeAxis(IReadOnlyList<AssemblyFaceContactEvidence> contacts) =>
|
|
contacts.Select(contact => contact.AxisA.Length >= 3 ? contact.AxisA : contact.AxisB)
|
|
.FirstOrDefault(axis => axis.Length >= 3) is { Length: >= 3 } axis ? CanonicalDirection(Vec.Normalize(axis)) : [];
|
|
|
|
static double[] RepresentativeNormal(IReadOnlyList<AssemblyFaceContactEvidence> contacts) =>
|
|
contacts.Select(contact => contact.NormalA.Length >= 3 ? contact.NormalA : contact.NormalB)
|
|
.FirstOrDefault(normal => normal.Length >= 3) is { Length: >= 3 } normal ? CanonicalDirection(Vec.Normalize(normal)) : [];
|
|
|
|
static double[] AverageCenters(IReadOnlyList<AssemblyFaceContactEvidence> contacts)
|
|
{
|
|
var centers = contacts.Select(contact => AverageVectors(contact.CenterA, contact.CenterB)).Where(center => center.Length >= 3).ToList();
|
|
return centers.Count == 0
|
|
? []
|
|
: [centers.Average(center => center[0]), centers.Average(center => center[1]), centers.Average(center => center[2])];
|
|
}
|
|
|
|
static double[] CanonicalDirection(double[] direction)
|
|
{
|
|
if (direction.Length < 3)
|
|
return [];
|
|
var first = direction.FirstOrDefault(value => Math.Abs(value) > 1e-6);
|
|
return first < 0 ? Vec.Mul(direction, -1) : direction;
|
|
}
|
|
|
|
static string VectorKey(double[] vector, double tolerance)
|
|
{
|
|
if (vector.Length < 3)
|
|
return "none";
|
|
tolerance = Math.Max(tolerance, 1e-6);
|
|
return string.Join(",", vector.Take(3).Select(value => Math.Round(value / tolerance).ToString(System.Globalization.CultureInfo.InvariantCulture)));
|
|
}
|
|
|
|
static HashSet<string> BuildPlanarProfilePairKeys(IReadOnlyList<AssemblyFaceContactEvidence> contacts)
|
|
{
|
|
var result = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
foreach (var group in contacts
|
|
.Where(contact => contact.ContactKind.Equals("planar_face_contact_candidate", StringComparison.OrdinalIgnoreCase))
|
|
.GroupBy(contact => ComponentPairKey(contact.ComponentA, contact.ComponentB), StringComparer.OrdinalIgnoreCase))
|
|
{
|
|
var items = group.ToList();
|
|
if (items.Count < 2)
|
|
continue;
|
|
|
|
var normalFamilies = new List<double[]>();
|
|
foreach (var contact in items)
|
|
{
|
|
var normal = contact.NormalA.Length >= 3 ? Vec.Normalize(contact.NormalA) : [];
|
|
if (normal.Length < 3)
|
|
continue;
|
|
|
|
var existing = normalFamilies.Any(family => Math.Abs(Vec.Dot(family, normal)) >= 0.92);
|
|
if (!existing)
|
|
normalFamilies.Add(normal);
|
|
}
|
|
|
|
// Multi-direction planar contacts indicate prismatic/profile fits such as square shafts,
|
|
// rectangular bosses, slots, or keyed interfaces. Same-direction multi-face groups are
|
|
// kept as weaker profile candidates; ordinary single flat mounting contacts are excluded.
|
|
if (normalFamilies.Count >= 2 || items.Count >= 3)
|
|
result.Add(group.Key);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
static bool ShouldExportFitLikeInterface(AssemblyFaceContactEvidence contact, IReadOnlySet<string> planarProfilePairKeys)
|
|
{
|
|
if (contact.ContactKind.Equals("cylindrical_coaxial_fit_candidate", StringComparison.OrdinalIgnoreCase))
|
|
return true;
|
|
|
|
if (!contact.ContactKind.Equals("planar_face_contact_candidate", StringComparison.OrdinalIgnoreCase))
|
|
return false;
|
|
|
|
return planarProfilePairKeys.Contains(ComponentPairKey(contact.ComponentA, contact.ComponentB));
|
|
}
|
|
|
|
static ComponentImageRef BuildComponentImageRef(
|
|
ComponentContextMember component,
|
|
IReadOnlyDictionary<string, AssemblyComponentSummary> componentsById)
|
|
{
|
|
return new ComponentImageRef
|
|
{
|
|
ComponentId = component.ComponentId,
|
|
InstanceName = component.InstanceName,
|
|
DisplayName = component.DisplayName,
|
|
ComponentPath = component.ComponentPath,
|
|
BBoxMm = componentsById.TryGetValue(component.ComponentId, out var summary)
|
|
? summary.BBoxMm
|
|
: []
|
|
};
|
|
}
|
|
|
|
static ComponentImageRef BuildComponentImageRef(
|
|
AssemblyComponentSummary? component,
|
|
string instanceName,
|
|
string displayName,
|
|
string componentPath)
|
|
{
|
|
return new ComponentImageRef
|
|
{
|
|
ComponentId = component?.Id ?? SanitizeId(FirstNonEmpty(instanceName, displayName, Path.GetFileNameWithoutExtension(componentPath), "component")),
|
|
InstanceName = component?.InstanceName ?? instanceName,
|
|
DisplayName = component?.DisplayName ?? displayName,
|
|
ComponentPath = component?.Path ?? componentPath,
|
|
BBoxMm = component?.BBoxMm ?? []
|
|
};
|
|
}
|
|
|
|
static AssemblyComponentSummary? FindComponentSummary(
|
|
IReadOnlyList<AssemblyComponentSummary> components,
|
|
string instanceName,
|
|
string componentPath)
|
|
{
|
|
return components.FirstOrDefault(component =>
|
|
!string.IsNullOrWhiteSpace(instanceName) &&
|
|
component.InstanceName.Equals(instanceName, StringComparison.OrdinalIgnoreCase) &&
|
|
(string.IsNullOrWhiteSpace(componentPath) ||
|
|
string.IsNullOrWhiteSpace(component.Path) ||
|
|
component.Path.Equals(componentPath, StringComparison.OrdinalIgnoreCase)))
|
|
?? components.FirstOrDefault(component =>
|
|
!string.IsNullOrWhiteSpace(componentPath) &&
|
|
!string.IsNullOrWhiteSpace(component.Path) &&
|
|
component.Path.Equals(componentPath, StringComparison.OrdinalIgnoreCase));
|
|
}
|
|
|
|
static double ContactPriority(AssemblyFaceContactEvidence contact)
|
|
{
|
|
var text = $"{contact.ContactKind} {contact.MateRole} {contact.FaceRoleA} {contact.FaceRoleB}".ToLowerInvariant();
|
|
if (text.Contains("cylindrical") || text.Contains("bearing") || text.Contains("hinge") || text.Contains("fit"))
|
|
return 3.0;
|
|
if (text.Contains("planar") || text.Contains("profile") || text.Contains("slot") || text.Contains("key"))
|
|
return 1.0;
|
|
return 0.0;
|
|
}
|
|
|
|
static string ComponentPairKey(string a, string b) =>
|
|
string.Compare(a, b, StringComparison.OrdinalIgnoreCase) <= 0
|
|
? $"{a}||{b}"
|
|
: $"{b}||{a}";
|
|
|
|
static double[] ChooseInterfaceHighlightDirection(
|
|
AssemblyFaceContactEvidence contact,
|
|
IReadOnlyList<AssemblyComponentSummary> components)
|
|
{
|
|
var center = AverageVectors(contact.CenterA, contact.CenterB);
|
|
var assemblyBox = UnionBoxes(components.Select(component => component.BBoxMm));
|
|
var outward = Array.Empty<double>();
|
|
if (center.Length >= 3 && assemblyBox.Length >= 6)
|
|
{
|
|
var away = Vec.Sub(center, BoxCenter(assemblyBox));
|
|
if (Vec.Norm(away) > 1.0)
|
|
outward = Vec.Normalize(away);
|
|
}
|
|
|
|
var axis = contact.AxisA.Length >= 3 ? Vec.Normalize(contact.AxisA) :
|
|
contact.AxisB.Length >= 3 ? Vec.Normalize(contact.AxisB) : [];
|
|
var normal = contact.NormalA.Length >= 3 ? contact.NormalA : contact.NormalB;
|
|
normal = normal.Length >= 3 ? Vec.Normalize(normal) : [];
|
|
var isCylindrical = contact.ContactKind.Contains("cylindrical", StringComparison.OrdinalIgnoreCase);
|
|
var componentA = FindComponentSummary(components, contact.ComponentA, contact.ComponentPathA);
|
|
var componentB = FindComponentSummary(components, contact.ComponentB, contact.ComponentPathB);
|
|
var boxA = componentA?.BBoxMm is { Length: >= 6 } ? componentA.BBoxMm : contact.BBoxA;
|
|
var boxB = componentB?.BBoxMm is { Length: >= 6 } ? componentB.BBoxMm : contact.BBoxB;
|
|
|
|
return InterfaceObliqueCandidateDirections()
|
|
.OrderByDescending(direction => ScoreInterfaceViewDirection(direction, boxA, boxB, outward, axis, normal, isCylindrical))
|
|
.First();
|
|
}
|
|
|
|
static double ScoreInterfaceViewDirection(double[] direction, double[] boxA, double[] boxB, double[] outward, double[] axis, double[] normal, bool isCylindrical)
|
|
{
|
|
var visibilityScore = ScorePairProjectedVisibility(boxA, boxB, direction);
|
|
|
|
var geometryPreference = 0.0;
|
|
if (outward.Length >= 3)
|
|
geometryPreference += 0.8 * Math.Max(0.0, Vec.Dot(direction, outward));
|
|
|
|
if (isCylindrical && axis.Length >= 3)
|
|
geometryPreference += 0.7 * (1.0 - Math.Abs(Vec.Dot(direction, axis)));
|
|
|
|
if (!isCylindrical && normal.Length >= 3)
|
|
geometryPreference += 0.7 * Math.Abs(Vec.Dot(direction, normal));
|
|
|
|
return visibilityScore * (1.0 + geometryPreference);
|
|
}
|
|
|
|
static double ScorePairProjectedVisibility(double[] boxA, double[] boxB, double[] direction)
|
|
{
|
|
if (boxA.Length < 6 || boxB.Length < 6)
|
|
return 1.0;
|
|
|
|
var projectionA = ProjectBoxForInterface(boxA, direction);
|
|
var projectionB = ProjectBoxForInterface(boxB, direction);
|
|
var areaA = projectionA.Area;
|
|
var areaB = projectionB.Area;
|
|
var overlap = OverlapArea(projectionA, projectionB);
|
|
var unionArea = Math.Max(1.0, areaA + areaB - overlap);
|
|
var smallerArea = Math.Max(1.0, Math.Min(areaA, areaB));
|
|
var largerArea = Math.Max(smallerArea, Math.Max(areaA, areaB));
|
|
var overlapRatio = Math.Min(1.0, overlap / smallerArea);
|
|
var balance = smallerArea / largerArea;
|
|
|
|
// Contact faces are usually hidden; favor views where both components expose
|
|
// enough surrounding exterior area instead of one box covering the other.
|
|
return unionArea * (1.0 - overlapRatio * 0.85) * (0.75 + balance * 0.25);
|
|
}
|
|
|
|
static InterfaceProjectionRect ProjectBoxForInterface(double[] box, double[] direction)
|
|
{
|
|
var d = Vec.Normalize(direction);
|
|
var reference = Math.Abs(Vec.Dot(d, [0.0, 1.0, 0.0])) > 0.92
|
|
? new[] { 0.0, 0.0, 1.0 }
|
|
: [0.0, 1.0, 0.0];
|
|
var u = Vec.Normalize(Vec.Cross(reference, d));
|
|
var v = Vec.Normalize(Vec.Cross(d, u));
|
|
var corners = BoxCorners(box);
|
|
var uRange = ProjectionRange(corners, u);
|
|
var vRange = ProjectionRange(corners, v);
|
|
return new InterfaceProjectionRect(uRange.Min, uRange.Max, vRange.Min, vRange.Max);
|
|
}
|
|
|
|
static double OverlapArea(InterfaceProjectionRect a, InterfaceProjectionRect b)
|
|
{
|
|
var u = Math.Max(0.0, Math.Min(a.UMax, b.UMax) - Math.Max(a.UMin, b.UMin));
|
|
var v = Math.Max(0.0, Math.Min(a.VMax, b.VMax) - Math.Max(a.VMin, b.VMin));
|
|
return u * v;
|
|
}
|
|
|
|
static List<double[]> BoxCorners(double[] box) =>
|
|
[
|
|
[box[0], box[1], box[2]],
|
|
[box[0], box[1], box[5]],
|
|
[box[0], box[4], box[2]],
|
|
[box[0], box[4], box[5]],
|
|
[box[3], box[1], box[2]],
|
|
[box[3], box[1], box[5]],
|
|
[box[3], box[4], box[2]],
|
|
[box[3], box[4], box[5]]
|
|
];
|
|
|
|
static (double Min, double Max) ProjectionRange(IReadOnlyList<double[]> points, double[] axis)
|
|
{
|
|
var values = points.Select(point => Vec.Dot(point, axis)).ToList();
|
|
return (values.Min(), values.Max());
|
|
}
|
|
|
|
static List<double[]> InterfaceObliqueCandidateDirections() =>
|
|
[
|
|
Vec.Normalize([1.0, 1.0, 1.0]),
|
|
Vec.Normalize([1.0, 1.0, -1.0]),
|
|
Vec.Normalize([1.0, -1.0, 1.0]),
|
|
Vec.Normalize([1.0, -1.0, -1.0]),
|
|
Vec.Normalize([-1.0, 1.0, 1.0]),
|
|
Vec.Normalize([-1.0, 1.0, -1.0]),
|
|
Vec.Normalize([-1.0, -1.0, 1.0]),
|
|
Vec.Normalize([-1.0, -1.0, -1.0])
|
|
];
|
|
|
|
static double[] AverageVectors(double[] a, double[] b)
|
|
{
|
|
if (a.Length < 3 && b.Length < 3)
|
|
return [];
|
|
if (a.Length < 3)
|
|
return b.Take(3).ToArray();
|
|
if (b.Length < 3)
|
|
return a.Take(3).ToArray();
|
|
return [(a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0, (a[2] + b[2]) / 2.0];
|
|
}
|
|
|
|
static bool ContainsAny(string value, params string[] tokens) =>
|
|
tokens.Any(token => value.Contains(token, StringComparison.OrdinalIgnoreCase));
|
|
|
|
static double[] ChooseComponentHighlightDirection(
|
|
AssemblyComponentSummary component,
|
|
IReadOnlyList<AssemblyComponentSummary> allComponents)
|
|
{
|
|
if (component.BBoxMm.Length < 6)
|
|
return [1.0, 1.0, 1.0];
|
|
|
|
var assemblyBox = UnionBoxes(allComponents.Select(item => item.BBoxMm));
|
|
if (assemblyBox.Length < 6)
|
|
return [1.0, 1.0, 1.0];
|
|
|
|
var componentCenter = BoxCenter(component.BBoxMm);
|
|
var assemblyCenter = BoxCenter(assemblyBox);
|
|
var delta = componentCenter.Zip(assemblyCenter, (a, b) => a - b).ToArray();
|
|
|
|
double Axis(double value, int index)
|
|
{
|
|
if (Math.Abs(value) > 1.0)
|
|
return Math.Sign(value);
|
|
|
|
var componentSpan = Math.Abs(component.BBoxMm[index + 3] - component.BBoxMm[index]);
|
|
var assemblySpan = Math.Abs(assemblyBox[index + 3] - assemblyBox[index]);
|
|
return componentSpan >= assemblySpan * 0.45 ? 1.0 : (index == 1 ? 1.0 : -1.0);
|
|
}
|
|
|
|
return Vec.Normalize([Axis(delta[0], 0), Axis(delta[1], 1), Axis(delta[2], 2)]);
|
|
}
|
|
|
|
static double[] UnionBoxes(IEnumerable<double[]> boxes)
|
|
{
|
|
var valid = boxes.Where(box => box.Length >= 6).ToList();
|
|
if (valid.Count == 0)
|
|
return [];
|
|
|
|
return
|
|
[
|
|
valid.Min(box => box[0]),
|
|
valid.Min(box => box[1]),
|
|
valid.Min(box => box[2]),
|
|
valid.Max(box => box[3]),
|
|
valid.Max(box => box[4]),
|
|
valid.Max(box => box[5])
|
|
];
|
|
}
|
|
|
|
static double[] BoxCenter(double[] box) =>
|
|
box.Length >= 6
|
|
? [(box[0] + box[3]) / 2.0, (box[1] + box[4]) / 2.0, (box[2] + box[5]) / 2.0]
|
|
: [];
|
|
|
|
static string SanitizeId(string value)
|
|
{
|
|
var chars = value.Select(ch => char.IsLetterOrDigit(ch) ? ch : '_').ToArray();
|
|
return new string(chars).Trim('_');
|
|
}
|
|
|
|
static string FirstNonEmpty(params string[] values) =>
|
|
values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)) ?? "";
|
|
}
|
|
|
|
readonly record struct InterfaceProjectionRect(double UMin, double UMax, double VMin, double VMax)
|
|
{
|
|
public double Area => Math.Max(1.0, (UMax - UMin) * (VMax - VMin));
|
|
}
|
|
|
|
sealed class PhysicalInterfaceGroup
|
|
{
|
|
public string BaseKey { get; set; } = "";
|
|
public string Id { get; set; } = "";
|
|
public List<AssemblyFaceContactEvidence> Contacts { get; set; } = [];
|
|
public List<string> FaceARefs { get; set; } = [];
|
|
public List<string> FaceBRefs { get; set; } = [];
|
|
public List<int> FaceAIndices { get; set; } = [];
|
|
public List<int> FaceBIndices { get; set; } = [];
|
|
public List<Face2> RuntimeFaces { get; set; } = [];
|
|
public double AnalysisPriority { get; set; }
|
|
public string ConfidenceTier { get; set; } = "";
|
|
}
|