Files
mech-ai/tools/model-diagnostics/StructuralFaultProbe/Program.cs
T
2026-07-17 17:45:56 +08:00

1300 lines
46 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using SolidWorks.Interop.sldworks;
using SolidWorks.Interop.swconst;
using System.Globalization;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
const int Asm = 2;
const int Silent = 1;
const int ReadOnly = 2;
if (args.Length == 0)
{
Console.Error.WriteLine("StructuralFaultProbe requires one or more assembly paths.");
return 2;
}
string outputDir = Path.Combine(System.Environment.CurrentDirectory, "runtime", "structural_fault_probe");
Directory.CreateDirectory(outputDir);
var assemblyPaths = args;
var sw = Connect();
var reports = new List<AssemblyReport>();
foreach (string rawPath in assemblyPaths)
{
string asmPath = Path.GetFullPath(rawPath);
if (!File.Exists(asmPath))
{
reports.Add(new AssemblyReport
{
AssemblyPath = asmPath,
Ok = false,
Message = "Assembly file does not exist."
});
continue;
}
reports.Add(AnalyzeAssembly(sw, asmPath));
}
var jsonOptions = new JsonSerializerOptions
{
WriteIndented = true,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
};
string jsonPath = Path.Combine(outputDir, "structural_fault_evidence.json");
File.WriteAllText(jsonPath, JsonSerializer.Serialize(reports, jsonOptions), new UTF8Encoding(false));
string mdPath = Path.Combine(outputDir, "structural_fault_evidence.md");
File.WriteAllText(mdPath, BuildMarkdown(reports), new UTF8Encoding(false));
Console.OutputEncoding = Encoding.UTF8;
Console.WriteLine("Structural fault probe completed.");
Console.WriteLine(jsonPath);
Console.WriteLine(mdPath);
return 0;
[DllImport("ole32.dll", CharSet = CharSet.Unicode)]
static extern int CLSIDFromProgID(string progId, out Guid clsid);
[DllImport("oleaut32.dll", PreserveSig = false)]
[return: MarshalAs(UnmanagedType.IUnknown)]
static extern object GetActiveObject(ref Guid clsid, IntPtr reserved);
static SldWorks Connect()
{
try
{
int hr = CLSIDFromProgID("SldWorks.Application", out var clsid);
if (hr < 0) Marshal.ThrowExceptionForHR(hr);
return (SldWorks)GetActiveObject(ref clsid, IntPtr.Zero);
}
catch
{
var type = Type.GetTypeFromProgID("SldWorks.Application")
?? throw new InvalidOperationException("SolidWorks ProgID not found.");
var sw = (SldWorks?)Activator.CreateInstance(type)
?? throw new InvalidOperationException("Cannot create SolidWorks.Application.");
sw.Visible = true;
return sw;
}
}
static AssemblyReport AnalyzeAssembly(SldWorks sw, string asmPath)
{
var report = new AssemblyReport
{
AssemblyPath = asmPath,
AssemblyName = Path.GetFileNameWithoutExtension(asmPath),
Ok = true
};
int errors = 0;
int warnings = 0;
var doc = sw.OpenDoc6(asmPath, Asm, Silent | ReadOnly, "", ref errors, ref warnings) as ModelDoc2;
report.OpenErrors = errors;
report.OpenWarnings = warnings;
if (doc is not AssemblyDoc assy)
{
report.Ok = false;
report.Message = $"OpenDoc6 failed or document is not an assembly. errors={errors}, warnings={warnings}";
return report;
}
report.AssemblyTitle = Safe(() => doc.GetTitle(), "");
dynamic mathUtility = sw.GetMathUtility();
var components = EnumerateComponents(assy);
report.ComponentCount = components.Count;
foreach (var component in components)
{
component.Faces = EnumerateFaces(component, mathUtility);
component.FaceCount = component.Faces.Count;
}
report.Components = components
.Select(c => new ComponentSummary
{
ComponentName = c.Name,
PartName = c.PartName,
Category = c.Category,
Path = c.Path,
FaceCount = c.FaceCount,
CylinderFaces = c.Faces
.Where(f => f.SurfaceType == "cylinder" && f.RadiusMm.HasValue && f.Axis != null && f.AxisPointMm != null)
.OrderByDescending(f => f.AreaMm2)
.Take(32)
.Select(FaceSummary.From)
.ToList()
})
.OrderBy(c => c.Category)
.ThenBy(c => c.ComponentName, StringComparer.OrdinalIgnoreCase)
.ToList();
var frames = components
.Select(c => new { Component = c, Frame = TryBuildAxialFrame(c) })
.Where(x => x.Frame != null)
.ToDictionary(x => x.Component.Name, x => x.Frame!, StringComparer.OrdinalIgnoreCase);
foreach (var component in components)
{
if (frames.TryGetValue(component.Name, out var frame))
report.ComponentFixations.Add(AnalyzeComponentFixation(component, components, frame));
}
var bearings = components.Where(c => c.Category == "bearing").ToList();
report.BearingCount = bearings.Count;
foreach (var bearing in bearings)
{
frames.TryGetValue(bearing.Name, out var frame);
report.Bearings.Add(AnalyzeBearingByFaceContact(bearing, components, frame));
}
report.Message = $"Analyzed {components.Count} components, {bearings.Count} bearing candidates.";
return report;
}
static List<ComponentInfo> EnumerateComponents(AssemblyDoc assy)
{
var result = new List<ComponentInfo>();
foreach (object item in assy.GetComponents(false) as object[] ?? Array.Empty<object>())
{
if (item is not Component2 comp)
continue;
string name = Safe(() => comp.Name2, "");
string path = Safe(() => comp.GetPathName(), "");
string partName = string.IsNullOrWhiteSpace(path)
? StripInstanceSuffix(name)
: Path.GetFileNameWithoutExtension(path);
result.Add(new ComponentInfo
{
Component = comp,
Name = name,
Path = path,
PartName = partName,
Category = ClassifyComponent(name, partName, path)
});
}
return result;
}
static string ClassifyComponent(string componentName, string partName, string path)
{
string text = $"{componentName} {partName} {Path.GetFileName(path)}".ToLowerInvariant();
if (ContainsAny(text, "轴承", "bearing", "gbt276", "gb276", "gbt", "7211", "620", "621", "630", "631", "600", "601"))
return "bearing";
if (ContainsAny(text, "端盖", "盖"))
return "end_cap";
if (ContainsAny(text, "箱体", "箱座", "箱盖", "housing"))
return "housing";
if (ContainsAny(text, "套筒", "隔套", "挡油环", "挡圈", "垫片", "螺母", "sleeve", "spacer", "ring"))
return "spacer_or_ring";
if (ContainsAny(text, "齿轮", "gear"))
return "gear";
if (ContainsAny(text, "轴", "shaft"))
return "shaft";
return "other";
}
static List<FaceInfo> EnumerateFaces(ComponentInfo component, dynamic mathUtility)
{
var result = new List<FaceInfo>();
var bodies = EnumerateComponentBodies(component.Component);
int faceIndex = 0;
foreach (var body in bodies)
{
for (object? faceObj = Safe<object?>(() => body.GetFirstFace(), null); faceObj != null;)
{
if (faceObj is not Face2 face)
break;
faceIndex++;
var info = BuildFaceInfo(component, mathUtility, face, faceIndex);
if (info != null)
result.Add(info);
faceObj = Safe<object?>(() => face.GetNextFace(), null);
}
}
return result;
}
static List<Body2> EnumerateComponentBodies(Component2 component)
{
var result = new List<Body2>();
try
{
if (component.GetModelDoc2() is PartDoc partDoc)
{
object bodiesObj = partDoc.GetBodies2((int)swBodyType_e.swSolidBody, true);
foreach (object bodyObj in ToObjectArray(bodiesObj))
if (bodyObj is Body2 body)
result.Add(body);
}
}
catch { }
if (result.Count > 0)
return result;
try
{
object bodyInfo = null!;
dynamic dynComponent = component;
object bodiesObj = dynComponent.GetBodies3((int)swBodyType_e.swSolidBody, out bodyInfo);
foreach (object bodyObj in ToObjectArray(bodiesObj))
if (bodyObj is Body2 body)
result.Add(body);
}
catch { }
return result;
}
static FaceInfo? BuildFaceInfo(ComponentInfo component, dynamic mathUtility, Face2 face, int faceIndex)
{
try
{
var surface = face.GetSurface() as Surface;
if (surface == null)
return null;
var localBox = ToDoubleArray(face.GetBox()).Take(6).Select(v => v * 1000.0).ToArray();
if (localBox.Length < 6)
return null;
var box = TransformBBox(mathUtility, component.Component, localBox);
var info = new FaceInfo
{
ComponentName = component.Name,
ComponentCategory = component.Category,
PartName = component.PartName,
FaceIndex = faceIndex,
BBoxMm = box,
CenterMm = TransformPoint(mathUtility, component.Component, [(localBox[0] + localBox[3]) / 2.0, (localBox[1] + localBox[4]) / 2.0, (localBox[2] + localBox[5]) / 2.0]),
AreaMm2 = Safe(() => face.GetArea() * 1_000_000.0, 0.0)
};
AddLoopInfo(face, info);
if (surface.IsPlane())
{
var p = ToDoubleArray(GetComProperty(surface, "PlaneParams"));
if (p.Length < 6)
return null;
info.SurfaceType = "plane";
info.Normal = Normalize(TransformVector(mathUtility, component.Component, [p[0], p[1], p[2]]));
if (Safe(() => face.FaceInSurfaceSense(), true) == false)
info.Normal = Scale(info.Normal, -1);
info.RootPointMm = TransformPoint(mathUtility, component.Component, [p[3] * 1000.0, p[4] * 1000.0, p[5] * 1000.0]);
info.PlaneOffsetMm = Dot(info.Normal, info.RootPointMm);
AddCircularEdgeRadii(face, info);
}
else if (surface.IsCylinder())
{
var c = ToDoubleArray(GetComProperty(surface, "CylinderParams"));
if (c.Length < 7)
return null;
info.SurfaceType = "cylinder";
info.AxisPointMm = TransformPoint(mathUtility, component.Component, [c[0] * 1000.0, c[1] * 1000.0, c[2] * 1000.0]);
info.Axis = Normalize(TransformVector(mathUtility, component.Component, [c[3], c[4], c[5]]));
info.RadiusMm = c[6] * 1000.0;
AddCylinderAxialRange(info);
}
else
{
info.SurfaceType = "other";
}
return info;
}
catch
{
return null;
}
}
static BearingReport AnalyzeBearing(ComponentInfo bearing, List<ComponentInfo> components)
{
var report = new BearingReport
{
ComponentName = bearing.Name,
PartName = bearing.PartName,
Path = bearing.Path,
FaceCount = bearing.FaceCount
};
var cylinders = bearing.Faces
.Where(f => f.SurfaceType == "cylinder" && f.RadiusMm.HasValue)
.OrderByDescending(f => f.AreaMm2)
.ToList();
report.CylinderFaces = cylinders
.Take(12)
.Select(f => FaceSummary.From(f))
.ToList();
var axisCluster = SelectBearingAxisCluster(cylinders);
if (axisCluster.Count == 0)
{
report.Status = "unknown";
report.Message = "No usable bearing cylindrical faces found.";
return report;
}
var outer = axisCluster.OrderByDescending(f => f.RadiusMm!.Value).First();
var inner = axisCluster.OrderBy(f => f.RadiusMm!.Value).First();
report.Axis = outer.Axis;
report.AxisPointMm = outer.AxisPointMm;
report.InnerRadiusMm = inner.RadiusMm;
report.OuterRadiusMm = outer.RadiusMm;
report.AxialMinMm = axisCluster.Min(f => f.AxialMinMm ?? Project(outer.AxisPointMm, outer));
report.AxialMaxMm = axisCluster.Max(f => f.AxialMaxMm ?? Project(outer.AxisPointMm, outer));
var planes = bearing.Faces
.Where(f => f.SurfaceType == "plane")
.Select(f => new
{
Face = f,
Axial = Project(f.CenterMm, outer)
})
.Where(x => x.Axial >= report.AxialMinMm - 1.0 && x.Axial <= report.AxialMaxMm + 1.0)
.ToList();
report.EndFaces = planes
.OrderBy(x => Math.Abs(x.Axial - report.AxialMinMm) < Math.Abs(x.Axial - report.AxialMaxMm) ? 0 : 1)
.ThenByDescending(x => x.Face.AreaMm2)
.Take(12)
.Select(x => FaceSummary.From(x.Face))
.ToList();
var innerLeftCandidates = CollectAxialStopCandidates(bearing, components, outer, inner, report.AxialMinMm, -1, "inner");
var innerRightCandidates = CollectAxialStopCandidates(bearing, components, outer, inner, report.AxialMaxMm, 1, "inner");
var outerLeftCandidates = CollectAxialStopCandidates(bearing, components, outer, inner, report.AxialMinMm, -1, "outer");
var outerRightCandidates = CollectAxialStopCandidates(bearing, components, outer, inner, report.AxialMaxMm, 1, "outer");
report.StopCandidates.AddRange(innerLeftCandidates.Take(20));
report.StopCandidates.AddRange(innerRightCandidates.Take(20));
report.StopCandidates.AddRange(outerLeftCandidates.Take(20));
report.StopCandidates.AddRange(outerRightCandidates.Take(20));
report.InnerLeftStop = innerLeftCandidates.FirstOrDefault(c => c.Accepted);
report.InnerRightStop = innerRightCandidates.FirstOrDefault(c => c.Accepted);
report.OuterLeftStop = outerLeftCandidates.FirstOrDefault(c => c.Accepted);
report.OuterRightStop = outerRightCandidates.FirstOrDefault(c => c.Accepted);
report.RuleInterpretation = new RuleInterpretation
{
Principle = "One side inner ring fixed and the opposite side outer ring fixed is treated as correct for this experiment.",
InnerLeft = report.InnerLeftStop != null,
InnerRight = report.InnerRightStop != null,
OuterLeft = report.OuterLeftStop != null,
OuterRight = report.OuterRightStop != null
};
bool patternA = report.InnerLeftStop != null && report.OuterRightStop != null;
bool patternB = report.InnerRightStop != null && report.OuterLeftStop != null;
report.RuleInterpretation.PatternInnerLeftOuterRight = patternA;
report.RuleInterpretation.PatternInnerRightOuterLeft = patternB;
report.RuleInterpretation.ExperimentalPass = patternA || patternB;
report.Status = report.RuleInterpretation.ExperimentalPass ? "pass" : "fail";
report.Message = report.RuleInterpretation.ExperimentalPass
? "Detected opposite-side inner/outer axial stop evidence."
: "Did not detect the required opposite-side inner/outer axial stop evidence.";
return report;
}
static BearingReport AnalyzeBearingByFaceContact(ComponentInfo bearing, List<ComponentInfo> components, AxialFrame? frame)
{
var report = new BearingReport
{
ComponentName = bearing.Name,
PartName = bearing.PartName,
Path = bearing.Path,
FaceCount = bearing.FaceCount
};
if (frame == null)
{
report.Status = "unknown";
report.Message = "No usable axial frame found for bearing.";
return report;
}
report.Axis = frame.Axis;
report.AxisPointMm = frame.AxisPointMm;
report.AxialMinMm = frame.MinAxialMm;
report.AxialMaxMm = frame.MaxAxialMm;
report.InnerRadiusMm = frame.InnerRadiusMm;
report.OuterRadiusMm = frame.OuterRadiusMm;
report.CylinderFaces = bearing.Faces
.Where(f => f.SurfaceType == "cylinder" && f.RadiusMm.HasValue)
.OrderByDescending(f => f.AreaMm2)
.Take(12)
.Select(FaceSummary.From)
.ToList();
var contacts = CollectSideContacts(bearing, components, frame);
report.SideContacts = contacts;
var leftInner = contacts.Where(c => c.Accepted && c.Side == "left" && c.BearingRing == "inner").OrderBy(c => c.Score).FirstOrDefault();
var leftOuter = contacts.Where(c => c.Accepted && c.Side == "left" && c.BearingRing == "outer").OrderBy(c => c.Score).FirstOrDefault();
var rightInner = contacts.Where(c => c.Accepted && c.Side == "right" && c.BearingRing == "inner").OrderBy(c => c.Score).FirstOrDefault();
var rightOuter = contacts.Where(c => c.Accepted && c.Side == "right" && c.BearingRing == "outer").OrderBy(c => c.Score).FirstOrDefault();
report.InnerLeftStop = leftInner?.ToStopEvidence();
report.InnerRightStop = rightInner?.ToStopEvidence();
report.OuterLeftStop = leftOuter?.ToStopEvidence();
report.OuterRightStop = rightOuter?.ToStopEvidence();
bool bothSidesContact = contacts.Any(c => c.Side == "left" && c.Accepted) && contacts.Any(c => c.Side == "right" && c.Accepted);
bool patternA = leftInner != null && rightOuter != null;
bool patternB = rightInner != null && leftOuter != null;
report.RuleInterpretation = new RuleInterpretation
{
Principle = "Bearing must have contacts on both axial sides; valid special bearing fixation requires one side on inner ring and the opposite side on outer ring.",
InnerLeft = leftInner != null,
InnerRight = rightInner != null,
OuterLeft = leftOuter != null,
OuterRight = rightOuter != null,
PatternInnerLeftOuterRight = patternA,
PatternInnerRightOuterLeft = patternB,
ExperimentalPass = bothSidesContact && (patternA || patternB)
};
report.Status = report.RuleInterpretation.ExperimentalPass ? "pass" : "fail";
report.Message = report.RuleInterpretation.ExperimentalPass
? "Both sides are contacted and opposite sides contact inner/outer rings."
: "Bearing does not satisfy both-side contact plus opposite inner/outer ring contact.";
return report;
}
static ComponentFixationReport AnalyzeComponentFixation(ComponentInfo target, List<ComponentInfo> components, AxialFrame frame)
{
var contacts = CollectSideContacts(target, components, frame);
bool left = contacts.Any(c => c.Side == "left" && c.Accepted);
bool right = contacts.Any(c => c.Side == "right" && c.Accepted);
return new ComponentFixationReport
{
ComponentName = target.Name,
PartName = target.PartName,
Category = target.Category,
Axis = Util.Round(frame.Axis),
AxisPointMm = Util.Round(frame.AxisPointMm),
AxialMinMm = Util.Round(frame.MinAxialMm),
AxialMaxMm = Util.Round(frame.MaxAxialMm),
InnerRadiusMm = Util.Round(frame.InnerRadiusMm),
OuterRadiusMm = Util.Round(frame.OuterRadiusMm),
LeftFixed = left,
RightFixed = right,
FixedBothSides = left && right,
Status = left && right ? "fixed_both_sides" : "not_fixed_both_sides",
Contacts = contacts.Take(40).ToList()
};
}
static AxialFrame? TryBuildAxialFrame(ComponentInfo component)
{
var cylinders = component.Faces
.Where(f => f.SurfaceType == "cylinder" && f.Axis != null && f.AxisPointMm != null && f.RadiusMm.HasValue)
.OrderByDescending(f => f.AreaMm2)
.ToList();
if (cylinders.Count > 0)
{
var seed = cylinders.First();
var cluster = cylinders
.Where(c => AreParallel(c.Axis!, seed.Axis!, 0.98))
.Where(c => DistancePointToLine(c.AxisPointMm!, seed.AxisPointMm!, seed.Axis!) < 1.0)
.ToList();
if (cluster.Count > 0)
{
double clusterMin = cluster.Min(f => f.AxialMinMm ?? Project(f.CenterMm, seed));
double clusterMax = cluster.Max(f => f.AxialMaxMm ?? Project(f.CenterMm, seed));
var radii = cluster.Select(c => c.RadiusMm!.Value).OrderBy(r => r).ToList();
return new AxialFrame
{
Axis = seed.Axis!,
AxisPointMm = seed.AxisPointMm!,
MinAxialMm = clusterMin,
MaxAxialMm = clusterMax,
InnerRadiusMm = radii.First(),
OuterRadiusMm = radii.Last()
};
}
}
var box = ComponentBBox(component);
if (box == null)
return null;
double dx = box[3] - box[0];
double dy = box[4] - box[1];
double dz = box[5] - box[2];
double[] axis;
double min;
double max;
if (dx <= dy && dx <= dz)
{
axis = [1, 0, 0];
min = box[0];
max = box[3];
}
else if (dy <= dx && dy <= dz)
{
axis = [0, 1, 0];
min = box[1];
max = box[4];
}
else
{
axis = [0, 0, 1];
min = box[2];
max = box[5];
}
return new AxialFrame
{
Axis = axis,
AxisPointMm = [(box[0] + box[3]) / 2.0, (box[1] + box[4]) / 2.0, (box[2] + box[5]) / 2.0],
MinAxialMm = min,
MaxAxialMm = max,
InnerRadiusMm = 0,
OuterRadiusMm = Math.Max(dx, Math.Max(dy, dz)) / 2.0
};
}
static List<SideContactEvidence> CollectSideContacts(ComponentInfo target, List<ComponentInfo> components, AxialFrame frame)
{
var result = new List<SideContactEvidence>();
double sideToleranceMm = 1.5;
double normalTolerance = 0.80;
foreach (var other in components)
{
if (other.Name == target.Name)
continue;
foreach (var face in other.Faces.Where(f => f.SurfaceType == "plane" && f.Normal != null))
{
double axial = ProjectOnFrame(face.CenterMm, frame);
string? side = null;
double gap = 0;
if (Math.Abs(axial - frame.MinAxialMm) <= sideToleranceMm)
{
side = "left";
gap = Math.Abs(axial - frame.MinAxialMm);
}
else if (Math.Abs(axial - frame.MaxAxialMm) <= sideToleranceMm)
{
side = "right";
gap = Math.Abs(axial - frame.MaxAxialMm);
}
else
{
continue;
}
double axisNormalAbs = Math.Abs(Dot(face.Normal!, frame.Axis));
string ring = ClassifyBearingRingByCoverage(face, frame);
var radialRange = FaceRadialRange(face, frame);
bool overlap = RingCoverage(face, frame, ring);
var reasons = new List<string>();
if (axisNormalAbs < normalTolerance)
reasons.Add($"plane is not perpendicular to axis enough: abs(dot)={Math.Round(axisNormalAbs, 4)}");
if (!overlap)
reasons.Add("face bbox does not overlap target radial envelope");
result.Add(new SideContactEvidence
{
Side = side,
ComponentName = other.Name,
PartName = other.PartName,
ComponentCategory = other.Category,
FaceIndex = face.FaceIndex,
CenterMm = Util.Round(face.CenterMm),
Normal = Util.Round(face.Normal!),
AxialPositionMm = Util.Round(axial),
AxialGapMm = Util.Round(gap),
AxisNormalAbs = Util.Round(axisNormalAbs),
BearingRing = ring,
FaceRadialMinMm = Util.Round(radialRange.Min),
FaceRadialMaxMm = Util.Round(radialRange.Max),
Accepted = reasons.Count == 0,
RejectReasons = reasons,
Score = Util.Round(gap * 10.0 + (1.0 - axisNormalAbs)),
Reason = "plane at target axial side with radial overlap"
});
}
}
return result
.OrderBy(c => c.Side)
.ThenBy(c => c.Score)
.ToList();
}
static string ClassifyBearingRingByCoverage(FaceInfo face, AxialFrame frame)
{
var (minR, maxR) = FaceRadialRange(face, frame);
double inner = frame.InnerRadiusMm;
double outer = frame.OuterRadiusMm;
bool coversInner = inner <= 0 || (minR <= inner + 3.0 && maxR >= Math.Max(0.0, inner - 3.0));
bool coversOuter = maxR >= outer - 3.0 && minR <= outer + 3.0;
if (coversInner && !coversOuter)
return "inner";
if (coversOuter && !coversInner)
return "outer";
if (coversInner && coversOuter)
return "full";
return "unknown";
}
static bool RingCoverage(FaceInfo face, AxialFrame frame, string ring)
{
var (minR, maxR) = FaceRadialRange(face, frame);
if (frame.OuterRadiusMm <= 0)
return true;
return maxR >= Math.Max(1.0, frame.InnerRadiusMm - 3.0) && minR <= frame.OuterRadiusMm + 3.0;
}
static (double Min, double Max) FaceRadialRange(FaceInfo face, AxialFrame frame)
{
if (face.CircularEdgeRadiiMm.Count > 0)
{
var radii = face.CircularEdgeRadiiMm.OrderBy(r => r).ToList();
double min = radii.Count == 1 ? 0.0 : radii.First();
double max = radii.Last();
return (min, max);
}
var radials = BBoxCorners(face.BBoxMm).Select(p => DistancePointToLine(p, frame.AxisPointMm, frame.Axis)).ToList();
return (radials.Min(), radials.Max());
}
static double[]? ComponentBBox(ComponentInfo component)
{
var boxes = component.Faces.Where(f => f.BBoxMm.Length >= 6).Select(f => f.BBoxMm).ToList();
if (boxes.Count == 0)
return null;
return
[
boxes.Min(b => b[0]),
boxes.Min(b => b[1]),
boxes.Min(b => b[2]),
boxes.Max(b => b[3]),
boxes.Max(b => b[4]),
boxes.Max(b => b[5])
];
}
static List<FaceInfo> SelectBearingAxisCluster(List<FaceInfo> cylinders)
{
if (cylinders.Count == 0)
return [];
FaceInfo seed = cylinders.OrderByDescending(c => c.AreaMm2).First();
return cylinders
.Where(c => c.Axis != null && c.AxisPointMm != null && c.RadiusMm.HasValue)
.Where(c => AreParallel(c.Axis!, seed.Axis!, 0.98))
.Where(c => DistancePointToLine(c.AxisPointMm!, seed.AxisPointMm!, seed.Axis!) < 0.5)
.ToList();
}
static List<StopEvidence> CollectAxialStopCandidates(
ComponentInfo bearing,
List<ComponentInfo> components,
FaceInfo outer,
FaceInfo inner,
double axialEnd,
int side,
string ring)
{
double innerRadius = inner.RadiusMm ?? 0.0;
double outerRadius = outer.RadiusMm ?? 0.0;
double targetRadius = ring == "inner" ? innerRadius : outerRadius;
double radialTolerance = Math.Max(1.5, outerRadius * 0.08);
double axialAcceptanceTolerance = 2.0;
double axialDebugTolerance = 30.0;
var candidates = new List<StopEvidence>();
foreach (var component in components)
{
if (component.Name == bearing.Name)
continue;
if (component.Category is "bearing" or "other")
continue;
foreach (var face in component.Faces)
{
if (face.SurfaceType != "plane" || face.Normal == null)
continue;
double axial = Project(face.CenterMm, outer);
double axialGap = Math.Abs(axial - axialEnd);
if (axialGap > axialDebugTolerance)
continue;
double radial = DistancePointToLine(face.CenterMm, outer.AxisPointMm!, outer.Axis!);
if (radial > outerRadius + 80.0)
continue;
var reasons = new List<string>();
bool categoryOk = ring == "inner"
? component.Category is "shaft" or "spacer_or_ring" or "gear"
: component.Category is "end_cap" or "housing" or "spacer_or_ring";
if (!categoryOk)
reasons.Add($"category {component.Category} is not valid for {ring} stop");
if (axialGap > axialAcceptanceTolerance)
reasons.Add($"axial gap {Math.Round(axialGap, 4)} mm exceeds {axialAcceptanceTolerance} mm");
double axisNormalAbs = Math.Abs(Dot(face.Normal, outer.Axis!));
if (axisNormalAbs < 0.85)
reasons.Add($"plane normal is not axial enough, abs(dot)={Math.Round(axisNormalAbs, 4)}");
if (!OverlapsProjectedBBox(face, outer, axialEnd, innerRadius, outerRadius, ring))
reasons.Add("projected bbox does not cover the required ring region");
double normalAlongAxis = Dot(face.Normal, outer.Axis!);
double expectedSign = -side;
double normalScore = Math.Abs(normalAlongAxis - expectedSign);
var evidence = new StopEvidence
{
ComponentName = component.Name,
PartName = component.PartName,
ComponentCategory = component.Category,
FaceIndex = face.FaceIndex,
SurfaceType = face.SurfaceType,
AreaMm2 = Util.Round(face.AreaMm2),
CenterMm = Util.Round(face.CenterMm),
Normal = Util.Round(face.Normal),
AxialPositionMm = Util.Round(axial),
AxialGapMm = Util.Round(Math.Abs(axial - axialEnd)),
RadialDistanceMm = Util.Round(radial),
Ring = ring,
Side = side < 0 ? "left" : "right",
Score = Util.Round(Math.Abs(axial - axialEnd) * 10.0 + normalScore + Math.Abs(radial - targetRadius) * (ring == "inner" ? 0.2 : 0.05)),
Accepted = reasons.Count == 0,
RejectReasons = reasons,
AxisNormalAbs = Util.Round(axisNormalAbs),
Reason = $"plane near bearing {ring} {(side < 0 ? "left" : "right")} end; component category={component.Category}"
};
candidates.Add(evidence);
}
}
return candidates
.OrderBy(c => c.Score)
.ThenBy(c => c.AxialGapMm)
.ToList();
}
static bool OverlapsProjectedBBox(FaceInfo face, FaceInfo bearingOuter, double axialEnd, double innerRadius, double outerRadius, string ring)
{
var axis = bearingOuter.Axis!;
var axisPoint = bearingOuter.AxisPointMm!;
var corners = BBoxCorners(face.BBoxMm);
var radials = corners.Select(c => DistancePointToLine(c, axisPoint, axis)).ToList();
double minR = radials.Min();
double maxR = radials.Max();
if (ring == "inner")
return minR <= innerRadius + 3.0 && maxR >= Math.Max(0.0, innerRadius - 3.0);
return maxR >= innerRadius + 1.0 && minR <= outerRadius + 3.0;
}
static void AddLoopInfo(Face2 face, FaceInfo info)
{
try
{
object loopsObj = face.GetLoops();
var loops = ToObjectArray(loopsObj);
info.LoopCount = loops.Length;
}
catch { }
}
static void AddCircularEdgeRadii(Face2 face, FaceInfo info)
{
try
{
foreach (object edgeObj in ToObjectArray(face.GetEdges()))
{
if (edgeObj is not Edge edge)
continue;
var curve = edge.GetCurve() as Curve;
if (curve == null || !Safe(() => curve.IsCircle(), false))
continue;
var cp = ToDoubleArray(GetComProperty(curve, "CircleParams"));
if (cp.Length >= 7)
{
double radiusMm = Math.Abs(cp[6]) * 1000.0;
if (radiusMm > 0.001 && !info.CircularEdgeRadiiMm.Any(r => Math.Abs(r - radiusMm) < 0.01))
info.CircularEdgeRadiiMm.Add(radiusMm);
}
}
info.CircularEdgeRadiiMm.Sort();
}
catch { }
}
static void AddCylinderAxialRange(FaceInfo info)
{
if (info.Axis == null || info.AxisPointMm == null || info.BBoxMm.Length < 6)
return;
var values = BBoxCorners(info.BBoxMm).Select(p => Project(p, info)).ToList();
info.AxialMinMm = values.Min();
info.AxialMaxMm = values.Max();
info.AxialLengthMm = info.AxialMaxMm - info.AxialMinMm;
}
static double Project(double[] point, FaceInfo axisFace)
{
return Dot(Sub(point, axisFace.AxisPointMm!), axisFace.Axis!);
}
static double ProjectOnFrame(double[] point, AxialFrame frame)
{
return Dot(Sub(point, frame.AxisPointMm), frame.Axis);
}
static string BuildMarkdown(List<AssemblyReport> reports)
{
var sb = new StringBuilder();
sb.AppendLine("# Structural Fault Evidence");
sb.AppendLine();
sb.AppendLine("Experimental rule: one side inner ring fixed and the opposite side outer ring fixed is treated as correct.");
sb.AppendLine();
foreach (var report in reports)
{
sb.AppendLine($"## {report.AssemblyName}");
sb.AppendLine();
sb.AppendLine($"- Path: `{report.AssemblyPath}`");
sb.AppendLine($"- Status: {(report.Ok ? "ok" : "failed")}");
sb.AppendLine($"- Message: {report.Message}");
sb.AppendLine($"- Components: {report.ComponentCount}, bearing candidates: {report.BearingCount}");
sb.AppendLine();
sb.AppendLine("### Component Side Fixation");
sb.AppendLine();
foreach (var fixation in report.ComponentFixations.OrderBy(f => f.Category).ThenBy(f => f.ComponentName, StringComparer.OrdinalIgnoreCase))
{
sb.AppendLine($"- `{fixation.Status}` {fixation.ComponentName}: left={fixation.LeftFixed}, right={fixation.RightFixed}");
foreach (var contact in fixation.Contacts.Where(c => c.Accepted).Take(4))
sb.AppendLine($" - {contact.Side}: {contact.ComponentName} face#{contact.FaceIndex}, ring={contact.BearingRing}, gap={contact.AxialGapMm} mm");
}
sb.AppendLine();
foreach (var bearing in report.Bearings)
{
sb.AppendLine($"### {bearing.ComponentName}");
sb.AppendLine();
sb.AppendLine($"- Diagnosis: `{bearing.Status}` - {bearing.Message}");
sb.AppendLine($"- Inner radius: {Util.Round(bearing.InnerRadiusMm)} mm, outer radius: {Util.Round(bearing.OuterRadiusMm)} mm");
sb.AppendLine($"- Axial range: {Util.Round(bearing.AxialMinMm)} to {Util.Round(bearing.AxialMaxMm)} mm");
sb.AppendLine($"- Inner left stop: {FormatStop(bearing.InnerLeftStop)}");
sb.AppendLine($"- Inner right stop: {FormatStop(bearing.InnerRightStop)}");
sb.AppendLine($"- Outer left stop: {FormatStop(bearing.OuterLeftStop)}");
sb.AppendLine($"- Outer right stop: {FormatStop(bearing.OuterRightStop)}");
sb.AppendLine("- Accepted side contacts:");
foreach (var contact in bearing.SideContacts.Where(c => c.Accepted).Take(10))
sb.AppendLine($" - {contact.Side}: {contact.ComponentName} face#{contact.FaceIndex}, ring={contact.BearingRing}, gap={contact.AxialGapMm} mm");
sb.AppendLine();
}
}
return sb.ToString();
}
static string FormatStop(StopEvidence? stop)
{
if (stop == null)
return "not detected";
return $"{stop.ComponentName} face#{stop.FaceIndex}, category={stop.ComponentCategory}, gap={stop.AxialGapMm} mm, radial={stop.RadialDistanceMm} mm";
}
static bool ContainsAny(string text, params string[] needles)
{
return needles.Any(n => text.Contains(n, StringComparison.OrdinalIgnoreCase));
}
static double[] TransformPoint(dynamic mathUtility, Component2 component, double[] pointMm)
{
try
{
object pointObj = mathUtility.CreatePoint(new[] { pointMm[0] / 1000.0, pointMm[1] / 1000.0, pointMm[2] / 1000.0 });
dynamic point = pointObj;
object transformObj = component.Transform2;
dynamic transformed = point.MultiplyTransform(transformObj);
return ToDoubleArray(transformed.ArrayData).Take(3).Select(v => v * 1000.0).ToArray();
}
catch
{
return pointMm;
}
}
static double[] TransformVector(dynamic mathUtility, Component2 component, double[] vector)
{
try
{
object vectorObj = mathUtility.CreateVector(vector);
dynamic mathVector = vectorObj;
object transformObj = component.Transform2;
dynamic transformed = mathVector.MultiplyTransform(transformObj);
return Normalize(ToDoubleArray(transformed.ArrayData).Take(3).ToArray());
}
catch
{
return Normalize(vector);
}
}
static double[] TransformBBox(dynamic mathUtility, Component2 component, double[] bboxMm)
{
var corners = BBoxCorners(bboxMm).Select(p => TransformPoint(mathUtility, component, p)).ToList();
return
[
corners.Min(p => p[0]),
corners.Min(p => p[1]),
corners.Min(p => p[2]),
corners.Max(p => p[0]),
corners.Max(p => p[1]),
corners.Max(p => p[2])
];
}
static string StripInstanceSuffix(string value)
{
int dash = value.LastIndexOf('-');
return dash > 0 && int.TryParse(value[(dash + 1)..], out _) ? value[..dash] : value;
}
static object? GetComProperty(object obj, string propertyName)
{
return obj.GetType().InvokeMember(propertyName, System.Reflection.BindingFlags.GetProperty, null, obj, null, CultureInfo.InvariantCulture);
}
static object[] ToObjectArray(object? value)
{
if (value == null)
return [];
if (value is object[] objects)
return objects;
if (value is Array array)
{
var result = new object[array.Length];
for (int i = 0; i < array.Length; i++)
result[i] = array.GetValue(i)!;
return result;
}
return [];
}
static double[] ToDoubleArray(object? value)
{
if (value == null)
return [];
if (value is double[] doubles)
return doubles;
if (value is object[] objects)
return objects.Select(o => Convert.ToDouble(o, CultureInfo.InvariantCulture)).ToArray();
if (value is Array array)
{
var result = new double[array.Length];
for (int i = 0; i < array.Length; i++)
result[i] = Convert.ToDouble(array.GetValue(i), CultureInfo.InvariantCulture);
return result;
}
return [];
}
static T Safe<T>(Func<T> fn, T fallback)
{
try { return fn(); }
catch { return fallback; }
}
static double Dot(double[] a, double[] b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
static double[] Sub(double[] a, double[] b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
static double[] Scale(double[] a, double s) => [a[0] * s, a[1] * s, a[2] * s];
static double Norm(double[] a) => Math.Sqrt(Dot(a, a));
static double[] Normalize(double[] a)
{
double n = Norm(a);
return n < 1e-12 ? [0, 0, 0] : [a[0] / n, a[1] / n, a[2] / n];
}
static bool AreParallel(double[] a, double[] b, double threshold) => Math.Abs(Dot(Normalize(a), Normalize(b))) >= threshold;
static double DistancePointToLine(double[] p, double[] linePoint, double[] lineDir)
{
var d = Normalize(lineDir);
var v = Sub(p, linePoint);
var projected = Scale(d, Dot(v, d));
return Norm(Sub(v, projected));
}
static List<double[]> BBoxCorners(double[] b)
{
return
[
[b[0], b[1], b[2]], [b[0], b[1], b[5]], [b[0], b[4], b[2]], [b[0], b[4], b[5]],
[b[3], b[1], b[2]], [b[3], b[1], b[5]], [b[3], b[4], b[2]], [b[3], b[4], b[5]]
];
}
sealed class ComponentInfo
{
public required Component2 Component { get; init; }
public string Name { get; init; } = "";
public string PartName { get; init; } = "";
public string Path { get; init; } = "";
public string Category { get; init; } = "";
public int FaceCount { get; set; }
public List<FaceInfo> Faces { get; set; } = [];
}
sealed class FaceInfo
{
public string ComponentName { get; set; } = "";
public string ComponentCategory { get; set; } = "";
public string PartName { get; set; } = "";
public int FaceIndex { get; set; }
public string SurfaceType { get; set; } = "";
public double AreaMm2 { get; set; }
public double[] BBoxMm { get; set; } = [];
public double[] CenterMm { get; set; } = [];
public int LoopCount { get; set; }
public double[]? Normal { get; set; }
public double[]? RootPointMm { get; set; }
public double? PlaneOffsetMm { get; set; }
public List<double> CircularEdgeRadiiMm { get; set; } = [];
public double[]? AxisPointMm { get; set; }
public double[]? Axis { get; set; }
public double? RadiusMm { get; set; }
public double? AxialMinMm { get; set; }
public double? AxialMaxMm { get; set; }
public double? AxialLengthMm { get; set; }
}
sealed class AssemblyReport
{
public bool Ok { get; set; }
public string Message { get; set; } = "";
public string AssemblyName { get; set; } = "";
public string AssemblyTitle { get; set; } = "";
public string AssemblyPath { get; set; } = "";
public int OpenErrors { get; set; }
public int OpenWarnings { get; set; }
public int ComponentCount { get; set; }
public int BearingCount { get; set; }
public List<ComponentSummary> Components { get; set; } = [];
public List<ComponentFixationReport> ComponentFixations { get; set; } = [];
public List<BearingReport> Bearings { get; set; } = [];
}
sealed class ComponentSummary
{
public string ComponentName { get; set; } = "";
public string PartName { get; set; } = "";
public string Category { get; set; } = "";
public string Path { get; set; } = "";
public int FaceCount { get; set; }
public List<FaceSummary> CylinderFaces { get; set; } = [];
}
sealed class BearingReport
{
public string ComponentName { get; set; } = "";
public string PartName { get; set; } = "";
public string Path { get; set; } = "";
public int FaceCount { get; set; }
public string Status { get; set; } = "";
public string Message { get; set; } = "";
public double[]? Axis { get; set; }
public double[]? AxisPointMm { get; set; }
public double? InnerRadiusMm { get; set; }
public double? OuterRadiusMm { get; set; }
public double AxialMinMm { get; set; }
public double AxialMaxMm { get; set; }
public List<FaceSummary> CylinderFaces { get; set; } = [];
public List<FaceSummary> EndFaces { get; set; } = [];
public List<SideContactEvidence> SideContacts { get; set; } = [];
public List<StopEvidence> StopCandidates { get; set; } = [];
public StopEvidence? InnerLeftStop { get; set; }
public StopEvidence? InnerRightStop { get; set; }
public StopEvidence? OuterLeftStop { get; set; }
public StopEvidence? OuterRightStop { get; set; }
public RuleInterpretation? RuleInterpretation { get; set; }
}
sealed class AxialFrame
{
public double[] Axis { get; set; } = [];
public double[] AxisPointMm { get; set; } = [];
public double MinAxialMm { get; set; }
public double MaxAxialMm { get; set; }
public double InnerRadiusMm { get; set; }
public double OuterRadiusMm { get; set; }
}
sealed class ComponentFixationReport
{
public string ComponentName { get; set; } = "";
public string PartName { get; set; } = "";
public string Category { get; set; } = "";
public string Status { get; set; } = "";
public double[] Axis { get; set; } = [];
public double[] AxisPointMm { get; set; } = [];
public double AxialMinMm { get; set; }
public double AxialMaxMm { get; set; }
public double? InnerRadiusMm { get; set; }
public double? OuterRadiusMm { get; set; }
public bool LeftFixed { get; set; }
public bool RightFixed { get; set; }
public bool FixedBothSides { get; set; }
public List<SideContactEvidence> Contacts { get; set; } = [];
}
sealed class SideContactEvidence
{
public string Side { get; set; } = "";
public string ComponentName { get; set; } = "";
public string PartName { get; set; } = "";
public string ComponentCategory { get; set; } = "";
public int FaceIndex { get; set; }
public string BearingRing { get; set; } = "";
public double FaceRadialMinMm { get; set; }
public double FaceRadialMaxMm { get; set; }
public double[] CenterMm { get; set; } = [];
public double[] Normal { get; set; } = [];
public double AxialPositionMm { get; set; }
public double AxialGapMm { get; set; }
public double AxisNormalAbs { get; set; }
public double Score { get; set; }
public bool Accepted { get; set; }
public List<string> RejectReasons { get; set; } = [];
public string Reason { get; set; } = "";
public StopEvidence ToStopEvidence()
{
return new StopEvidence
{
ComponentName = ComponentName,
PartName = PartName,
ComponentCategory = ComponentCategory,
FaceIndex = FaceIndex,
SurfaceType = "plane",
CenterMm = CenterMm,
Normal = Normal,
AxialPositionMm = AxialPositionMm,
AxialGapMm = AxialGapMm,
AxisNormalAbs = AxisNormalAbs,
Ring = BearingRing,
Side = Side,
Score = Score,
Accepted = Accepted,
RejectReasons = RejectReasons,
Reason = Reason
};
}
}
sealed class RuleInterpretation
{
public string Principle { get; set; } = "";
public bool InnerLeft { get; set; }
public bool InnerRight { get; set; }
public bool OuterLeft { get; set; }
public bool OuterRight { get; set; }
public bool PatternInnerLeftOuterRight { get; set; }
public bool PatternInnerRightOuterLeft { get; set; }
public bool ExperimentalPass { get; set; }
}
sealed class StopEvidence
{
public string ComponentName { get; set; } = "";
public string PartName { get; set; } = "";
public string ComponentCategory { get; set; } = "";
public int FaceIndex { get; set; }
public string SurfaceType { get; set; } = "";
public double AreaMm2 { get; set; }
public double[] CenterMm { get; set; } = [];
public double[] Normal { get; set; } = [];
public double AxialPositionMm { get; set; }
public double AxialGapMm { get; set; }
public double RadialDistanceMm { get; set; }
public double AxisNormalAbs { get; set; }
public string Ring { get; set; } = "";
public string Side { get; set; } = "";
public double Score { get; set; }
public bool Accepted { get; set; }
public List<string> RejectReasons { get; set; } = [];
public string Reason { get; set; } = "";
}
static class Util
{
public static double? Round(double? value) => value.HasValue ? Math.Round(value.Value, 4) : null;
public static double Round(double value) => Math.Round(value, 4);
public static double[] Round(double[] value) => value.Select(v => Math.Round(v, 4)).ToArray();
}
sealed class FaceSummary
{
public int FaceIndex { get; set; }
public string SurfaceType { get; set; } = "";
public double AreaMm2 { get; set; }
public double[] CenterMm { get; set; } = [];
public double[] BBoxMm { get; set; } = [];
public int LoopCount { get; set; }
public double? RadiusMm { get; set; }
public double[]? AxisPointMm { get; set; }
public double[]? Axis { get; set; }
public double? AxialMinMm { get; set; }
public double? AxialMaxMm { get; set; }
public static FaceSummary From(FaceInfo face)
{
return new FaceSummary
{
FaceIndex = face.FaceIndex,
SurfaceType = face.SurfaceType,
AreaMm2 = Util.Round(face.AreaMm2),
CenterMm = Util.Round(face.CenterMm),
BBoxMm = Util.Round(face.BBoxMm),
LoopCount = face.LoopCount,
RadiusMm = Util.Round(face.RadiusMm),
AxisPointMm = face.AxisPointMm == null ? null : Util.Round(face.AxisPointMm),
Axis = face.Axis == null ? null : Util.Round(face.Axis),
AxialMinMm = Util.Round(face.AxialMinMm),
AxialMaxMm = Util.Round(face.AxialMaxMm)
};
}
}