first commit
This commit is contained in:
@@ -0,0 +1,826 @@
|
||||
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; } = "";
|
||||
}
|
||||
Reference in New Issue
Block a user