Files
2026-07-17 17:45:56 +08:00

6895 lines
286 KiB
C#

using SolidWorks.Interop.sldworks;
using SolidWorks.Interop.swconst;
using System.Diagnostics;
using System.Globalization;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Serialization;
const int PartDoc = 1;
const int AssemblyDocType = 2;
const int Silent = 1;
const int ReadOnly = 2;
const double CylindricalParamDomainAxialStepMm = 2.0;
const int CylindricalBoundarySamplesPerEdge = 24;
const int CylindricalFallbackAngularSectors = 8;
const double CylindricalFaceMembershipToleranceMm = 0.03;
const int MkEUnavailable = unchecked((int)0x800401E3);
const int RpcECallRejected = unchecked((int)0x80010001);
const int RpcEServerCallRetryLater = unchecked((int)0x8001010A);
Console.OutputEncoding = Encoding.UTF8;
try
{
if (OperatingSystem.IsWindows() && Thread.CurrentThread.GetApartmentState() != ApartmentState.STA)
Thread.CurrentThread.SetApartmentState(ApartmentState.STA);
}
catch
{
// The run status records later COM failures; some hosts initialize the apartment before user code.
}
if (args.Length == 0)
{
Console.WriteLine("Usage: dotnet run --project tools\\SectionBrepExtractor\\SectionBrepExtractor.csproj -c Release -- <SLDPRT-or-SLDASM> [axisX axisY axisZ] [--up-axis x y z] [--output-dir <dir>] [--export-images] [--image-dir <dir>] [--image-views front,back,top,bottom,left,right,isometric]. SLDPRT and SLDASM use separate processing policies.");
System.Environment.Exit(2);
}
var command = ParseCommandLine(args);
var path = Path.GetFullPath(command.Path);
var axis = command.Axis;
var outDir = command.OutputDir
?? (!string.IsNullOrWhiteSpace(command.ImageOnlyReportPath)
? Path.GetDirectoryName(command.ImageOnlyReportPath) ?? Path.Combine(System.Environment.CurrentDirectory, "tools", "SectionBrepExtractor", "output")
: Path.Combine(System.Environment.CurrentDirectory, "tools", "SectionBrepExtractor", "output"));
Directory.CreateDirectory(outDir);
var jsonPath = Path.Combine(outDir, "section_brep_report.json");
var failedJsonPath = Path.Combine(outDir, "section_brep_report.failed.json");
var mdPath = Path.Combine(outDir, "section_brep_report.md");
var statusPath = Path.Combine(outDir, "run_status.json");
var options = new JsonSerializerOptions
{
WriteIndented = true,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
};
var status = new ExtractionRunStatus
{
InputPath = path,
OutputDir = outDir,
StartedAt = DateTimeOffset.Now,
CurrentStage = "starting",
Axis = axis
};
void Mark(string stage, string message = "")
{
status.CurrentStage = stage;
status.UpdatedAt = DateTimeOffset.Now;
status.Stages.Add(new ExtractionStageLog
{
Stage = stage,
Message = message,
Timestamp = status.UpdatedAt
});
WriteStatus();
}
void WriteStatus() =>
WriteAllTextWithRetry(statusPath, JsonSerializer.Serialize(status, options), new UTF8Encoding(false));
static void WriteAllTextWithRetry(string path, string contents, Encoding encoding, int maxAttempts = 12)
{
for (var attempt = 1; attempt <= maxAttempts; attempt++)
{
try
{
File.WriteAllText(path, contents, encoding);
return;
}
catch (IOException) when (attempt < maxAttempts)
{
Thread.Sleep(Math.Min(1000, attempt * 100));
}
}
File.WriteAllText(path, contents, encoding);
}
try
{
Mark("connect_solidworks");
var sw = Connect(Mark);
if (!string.IsNullOrWhiteSpace(command.FaceHighlightPlanPath))
{
var imageOnlyReport = RunFaceHighlightPlan(sw, command.FaceHighlightPlanPath, outDir, Mark);
status.CompletedAt = DateTimeOffset.Now;
status.Success = imageOnlyReport.Success;
status.ReportPath = imageOnlyReport.ReportPath;
status.CurrentStage = imageOnlyReport.Success ? "completed" : "completed_with_image_errors";
WriteStatus();
Console.WriteLine($"Face highlight images exported: {imageOnlyReport.ReportPath}");
System.Environment.Exit(imageOnlyReport.Success ? 0 : 4);
}
if (!string.IsNullOrWhiteSpace(command.ImageOnlyReportPath))
{
var imageOnlyExistingReport = RunImageOnlyFromExistingReport(sw, path, command.ImageOnlyReportPath, outDir, command.ImageDir, Mark);
status.CompletedAt = DateTimeOffset.Now;
status.Success = imageOnlyExistingReport.Ok;
status.ReportPath = command.ImageOnlyReportPath;
status.CurrentStage = imageOnlyExistingReport.Ok ? "completed" : "completed_with_image_errors";
WriteStatus();
Console.WriteLine($"Images re-exported from existing report: {command.ImageOnlyReportPath}");
System.Environment.Exit(imageOnlyExistingReport.Ok ? 0 : 4);
}
Mark("extract_model_brep", "connected to SolidWorks");
var report = ExtractSectionBrep(
sw,
path,
axis,
command.UpAxis,
command.SkipSectionBrep,
command.PurchasedComponentHints,
command.ExternalInterfaceRoots,
Mark);
report.Ok = !report.Message.Contains("failed", StringComparison.OrdinalIgnoreCase) &&
!report.Message.Contains("No cylindrical axis group", StringComparison.OrdinalIgnoreCase);
report.Path = path;
report.OutputDir = outDir;
report.StartedAt = status.StartedAt;
report.CompletedAt = DateTimeOffset.Now;
report.StageLogs = status.Stages;
if (command.ExportImages)
{
var hasExternalInterfaceRoots = command.ExternalInterfaceRoots.Count > 0;
var componentHighlightRequests = report.DocumentKind.Equals("part", StringComparison.OrdinalIgnoreCase)
? PartModelProcessing.BuildComponentHighlightImageRequests(report)
: AssemblyModelProcessing.BuildComponentHighlightImageRequests(report);
var interfaceHighlightRequests = report.DocumentKind.Equals("part", StringComparison.OrdinalIgnoreCase)
? []
: AssemblyModelProcessing.BuildInterfaceHighlightImageRequests(report, compactViews: hasExternalInterfaceRoots);
var featureHighlightRequests = report.DocumentKind.Equals("part", StringComparison.OrdinalIgnoreCase)
? PartModelProcessing.BuildFeatureHighlightImageRequests(report)
: [];
var componentContextRequests = report.DocumentKind.Equals("part", StringComparison.OrdinalIgnoreCase)
? PartModelProcessing.BuildComponentContextImageRequests(report)
: AssemblyModelProcessing.BuildComponentContextImageRequests(report);
var sectionViewRequests = report.DocumentKind.Equals("part", StringComparison.OrdinalIgnoreCase)
? PartModelProcessing.BuildSectionViewRequests(report)
: [];
var standardViewRequests = new List<string>();
var sectionPlanNote = report.DocumentKind.Equals("part", StringComparison.OrdinalIgnoreCase)
? $"part_axis_section_views={sectionViewRequests.Count}"
: "assembly_sections=disabled";
Mark("export_model_images", $"kind={report.DocumentKind}; views={string.Join(",", standardViewRequests)}; {sectionPlanNote}; component_highlights={componentHighlightRequests.Count}; interface_highlights={interfaceHighlightRequests.Count}; feature_highlights={featureHighlightRequests.Count}; component_contexts={componentContextRequests.Count}");
var imageDir = command.ImageDir ?? Path.Combine(outDir, "model_images");
report.ImageExport = ModelImageExporter.Export(sw, path, imageDir, standardViewRequests, sectionViewRequests, 0, componentHighlightRequests, interfaceHighlightRequests, featureHighlightRequests, componentContextRequests, Mark);
}
Mark("write_report", report.Message);
File.WriteAllText(jsonPath, JsonSerializer.Serialize(report, options), new UTF8Encoding(false));
File.WriteAllText(mdPath, BuildMarkdown(report), new UTF8Encoding(false));
status.CompletedAt = DateTimeOffset.Now;
status.Success = report.Ok;
status.ReportPath = jsonPath;
status.CurrentStage = "completed";
WriteStatus();
Console.WriteLine($"Section B-rep extracted: {jsonPath}");
Console.WriteLine(mdPath);
System.Environment.Exit(report.Ok ? 0 : 3);
}
catch (Exception ex)
{
status.CompletedAt = DateTimeOffset.Now;
status.Success = false;
status.Error = ex.ToString();
status.CurrentStage = "failed";
var failed = new SectionBrepReport
{
Ok = false,
Path = path,
OutputDir = outDir,
StartedAt = status.StartedAt,
CompletedAt = status.CompletedAt,
Message = $"Extraction failed at stage `{status.CurrentStage}`: {ex.Message}",
Error = ex.ToString(),
StageLogs = status.Stages
};
File.WriteAllText(failedJsonPath, JsonSerializer.Serialize(failed, options), new UTF8Encoding(false));
File.WriteAllText(mdPath, BuildMarkdown(failed), new UTF8Encoding(false));
status.ReportPath = failedJsonPath;
WriteStatus();
Console.Error.WriteLine(ex);
System.Environment.Exit(1);
}
[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(Action<string, string> log)
{
Exception? activeObjectError = null;
int hr = CLSIDFromProgID("SldWorks.Application", out var clsid);
if (hr < 0)
Marshal.ThrowExceptionForHR(hr);
var attachTimeoutSeconds = GetSolidWorksAttachTimeoutSeconds(IsSolidWorksProcessAlive());
log("connect_attach_timeout", $"{attachTimeoutSeconds} seconds");
var start = DateTimeOffset.Now;
while ((DateTimeOffset.Now - start).TotalSeconds < attachTimeoutSeconds)
{
try
{
log("connect_get_active_object", "SldWorks.Application");
var sw = (SldWorks)GetActiveObject(ref clsid, IntPtr.Zero);
_ = sw.Visible;
log("connect_get_active_object_ok", "attached to running SolidWorks instance");
return sw;
}
catch (COMException ex) when (ex.ErrorCode is MkEUnavailable or RpcECallRejected or RpcEServerCallRetryLater)
{
activeObjectError = ex;
log("connect_get_active_object_wait", $"0x{ex.ErrorCode:X8} {ex.Message}");
Thread.Sleep(1000);
}
catch (Exception ex)
{
activeObjectError = ex;
log("connect_get_active_object_failed", ex.Message);
Thread.Sleep(1000);
}
}
log("connect_get_active_object_unavailable", $"SldWorks.Application was not available from ROT within {attachTimeoutSeconds} seconds. Last error: {activeObjectError?.Message}");
log("connect_create_instance", "SldWorks.Application");
var type = Type.GetTypeFromProgID("SldWorks.Application")
?? throw new InvalidOperationException("SolidWorks ProgID not found.");
try
{
var sw = (SldWorks?)Activator.CreateInstance(type)
?? throw new InvalidOperationException("Cannot create SolidWorks.Application.");
sw.Visible = true;
log("connect_create_instance_ok", "created or attached to SolidWorks automation instance");
return sw;
}
catch (Exception createEx)
{
throw new InvalidOperationException(
"Cannot connect to SolidWorks. " +
$"GetActiveObject did not expose SldWorks.Application from ROT: {activeObjectError?.Message}. " +
$"CreateInstance failed: {createEx.Message}. " +
"Check that SolidWorks is fully started, no modal startup dialog is blocking it, and this extractor is running at the same elevation level as SolidWorks.",
createEx);
}
}
static FaceHighlightPlanRunReport RunFaceHighlightPlan(
SldWorks sw,
string planPath,
string outDir,
Action<string, string> log)
{
log("face_highlight_plan_read", planPath);
if (!File.Exists(planPath))
throw new FileNotFoundException($"Face highlight plan not found: {planPath}");
var plan = JsonSerializer.Deserialize<FaceHighlightExportPlan>(
File.ReadAllText(planPath),
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
?? throw new InvalidOperationException($"Cannot parse face highlight plan: {planPath}");
Directory.CreateDirectory(outDir);
var startedAt = DateTimeOffset.Now;
var reportPath = Path.Combine(outDir, string.IsNullOrWhiteSpace(plan.ReportFileName) ? "face_highlight_export_manifest.json" : plan.ReportFileName);
var groups = plan.Groups ?? [];
var allRequests = groups
.SelectMany(group => group.Components ?? [], (group, component) => new { group, component })
.Where(item => !string.IsNullOrWhiteSpace(item.component.ComponentPath))
.GroupBy(item => Path.GetFullPath(item.component.ComponentPath), StringComparer.OrdinalIgnoreCase)
.ToList();
var groupReports = groups.ToDictionary(
group => group.GroupId ?? "",
group => new FaceHighlightGroupRunReport
{
GroupId = group.GroupId ?? "",
Directory = group.Directory ?? "",
Components = []
},
StringComparer.OrdinalIgnoreCase);
var componentReports = new Dictionary<string, FaceHighlightComponentRunReport>(StringComparer.OrdinalIgnoreCase);
foreach (var modelGroup in allRequests)
{
var modelPath = modelGroup.Key;
var requests = new List<FeatureHighlightImageRequest>();
foreach (var item in modelGroup)
{
var groupId = item.group.GroupId ?? "";
var component = item.component;
var componentKey = $"{groupId}::{component.ComponentId}";
var componentReport = new FaceHighlightComponentRunReport
{
ComponentId = component.ComponentId ?? "",
InstanceName = component.InstanceName ?? "",
DisplayName = component.DisplayName ?? "",
ComponentPath = component.ComponentPath ?? "",
RequestedFaceCount = component.Faces?.Count ?? 0
};
componentReports[componentKey] = componentReport;
if (groupReports.TryGetValue(groupId, out var groupReport))
groupReport.Components.Add(componentReport);
foreach (var face in component.Faces ?? [])
{
var faceRef = FirstNonEmpty(face.FaceRef, BuildFaceRef(component.InstanceName, face.FaceIndex));
if (string.IsNullOrWhiteSpace(faceRef))
continue;
var faceIndex = face.FaceIndex > 0 ? face.FaceIndex : ParseFaceIndex(faceRef);
var faceKey = faceIndex > 0 ? $"face_{faceIndex:0000}" : SanitizeFileName(faceRef);
requests.Add(new FeatureHighlightImageRequest
{
PlanId = FirstNonEmpty(face.PlanId, $"{SanitizeFileName(groupId)}_{SanitizeFileName(component.ComponentId ?? component.InstanceName ?? "component")}_{faceKey}"),
ComponentId = component.ComponentId ?? "",
InstanceName = component.InstanceName ?? "",
DisplayName = component.DisplayName ?? "",
ComponentPath = component.ComponentPath ?? "",
FeatureId = FirstNonEmpty(face.FeatureId, faceKey),
FeatureType = FirstNonEmpty(face.FaceKind, "single_face"),
FaceRefs = [faceRef],
Views = face.Views is { Count: > 0 } ? face.Views : ["isometric"],
PreferredDirectionMm = face.PreferredDirectionMm is { Length: >= 3 } ? face.PreferredDirectionMm : [1.0, 1.0, 1.0],
SelectionBasis = "functional_group_all_face_highlight_plan_reusing_initial_brep_face_refs",
ImageKind = "functional_group_all_face_highlight",
EvidenceFamily = "part_all_face_surface",
EvidencePurpose = "post_functional_group_part_and_surface_diagnosis",
OutputRelativeDirectory = FirstNonEmpty(
face.OutputRelativeDirectory,
Path.Combine(item.group.Directory ?? SanitizeFileName(groupId), "all_face_highlights", SanitizeFileName(component.ComponentId ?? component.InstanceName ?? "component")).Replace('\\', '/')),
OutputFileStem = FirstNonEmpty(face.OutputFileStem, faceKey)
});
}
}
if (requests.Count == 0)
continue;
log("face_highlight_plan_export_model", $"{modelPath}; requests={requests.Count}");
var imageReport = ModelImageExporter.Export(
sw,
modelPath,
outDir,
[],
[],
0,
[],
[],
requests,
[],
log);
foreach (var view in imageReport.Views)
{
var groupId = ResolveGroupIdFromOutputDirectory(view.OutputPath, outDir, groups);
var componentKey = $"{groupId}::{view.HighlightComponentId}";
if (!componentReports.TryGetValue(componentKey, out var componentReport))
continue;
componentReport.Views.Add(view);
}
}
foreach (var groupReport in groupReports.Values)
{
foreach (var component in groupReport.Components)
{
component.ExportedFaceCount = component.Views
.Where(view => view.Ok)
.SelectMany(view => view.HighlightFaceRefs)
.Distinct(StringComparer.OrdinalIgnoreCase)
.Count();
}
groupReport.RequestedFaceCount = groupReport.Components.Sum(component => component.RequestedFaceCount);
groupReport.ExportedFaceCount = groupReport.Components.Sum(component => component.ExportedFaceCount);
}
var runReport = new FaceHighlightPlanRunReport
{
Schema = "functional_group_all_face_highlight_export_manifest_v1",
GenerationMode = "image_only_from_functional_group_plan_no_brep_extraction",
PlanPath = planPath,
OutputDir = outDir,
StartedAt = startedAt,
CompletedAt = DateTimeOffset.Now,
Groups = groupReports.Values.OrderBy(group => group.GroupId, StringComparer.OrdinalIgnoreCase).ToList()
};
runReport.ReportPath = reportPath;
runReport.Success = runReport.Groups.All(group => group.Components.All(component => component.Views.Count == 0 || component.Views.All(view => view.Ok)));
runReport.Message = $"Exported {runReport.Groups.Sum(group => group.ExportedFaceCount)}/{runReport.Groups.Sum(group => group.RequestedFaceCount)} requested face highlight images.";
File.WriteAllText(reportPath, JsonSerializer.Serialize(runReport, new JsonSerializerOptions
{
WriteIndented = true,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
}), new UTF8Encoding(false));
return runReport;
}
static SectionBrepReport RunImageOnlyFromExistingReport(
SldWorks sw,
string requestedModelPath,
string reportPath,
string outDir,
string? imageDirOverride,
Action<string, string> log)
{
log("image_only_report_read", reportPath);
if (!File.Exists(reportPath))
throw new FileNotFoundException($"Existing section_brep_report.json not found: {reportPath}");
var jsonOptions = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
WriteIndented = true,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
};
var report = JsonSerializer.Deserialize<SectionBrepReport>(File.ReadAllText(reportPath), jsonOptions)
?? throw new InvalidOperationException($"Cannot parse existing report: {reportPath}");
var modelPath = ResolveImageOnlyModelPath(requestedModelPath, report.Path);
if (!File.Exists(modelPath))
throw new FileNotFoundException($"Model path for image-only export not found: {modelPath}");
var componentHighlightRequests = report.DocumentKind.Equals("part", StringComparison.OrdinalIgnoreCase)
? PartModelProcessing.BuildComponentHighlightImageRequests(report)
: AssemblyModelProcessing.BuildComponentHighlightImageRequests(report);
var interfaceHighlightRequests = report.DocumentKind.Equals("part", StringComparison.OrdinalIgnoreCase)
? []
: AssemblyModelProcessing.BuildInterfaceHighlightImageRequests(report);
var featureHighlightRequests = report.DocumentKind.Equals("part", StringComparison.OrdinalIgnoreCase)
? PartModelProcessing.BuildFeatureHighlightImageRequests(report)
: [];
var componentContextRequests = report.DocumentKind.Equals("part", StringComparison.OrdinalIgnoreCase)
? PartModelProcessing.BuildComponentContextImageRequests(report)
: AssemblyModelProcessing.BuildComponentContextImageRequests(report);
var sectionViewRequests = report.DocumentKind.Equals("part", StringComparison.OrdinalIgnoreCase)
? PartModelProcessing.BuildSectionViewRequests(report)
: [];
var standardViewRequests = new List<string>();
var imageDir = imageDirOverride ?? Path.Combine(outDir, "model_images");
log(
"export_model_images_from_existing_report",
$"kind={report.DocumentKind}; component_highlights={componentHighlightRequests.Count}; interface_highlights={interfaceHighlightRequests.Count}; feature_highlights={featureHighlightRequests.Count}; component_contexts={componentContextRequests.Count}; no_brep_reextract=true");
report.ImageExport = ModelImageExporter.Export(
sw,
modelPath,
imageDir,
standardViewRequests,
sectionViewRequests,
0,
componentHighlightRequests,
interfaceHighlightRequests,
featureHighlightRequests,
componentContextRequests,
log);
report.Path = modelPath;
report.OutputDir = outDir;
report.CompletedAt = DateTimeOffset.Now;
report.StageLogs.Add(new ExtractionStageLog
{
Stage = "image_only_from_existing_report",
Message = $"Re-exported images without B-rep extraction. component_highlights={componentHighlightRequests.Count}; success={report.ImageExport.SuccessCount}/{report.ImageExport.Views.Count}",
Timestamp = DateTimeOffset.Now
});
report.Ok = report.ImageExport.Views.All(view => view.Ok);
report.Message = $"Image-only export from existing B-rep report completed: {report.ImageExport.SuccessCount}/{report.ImageExport.Views.Count} images.";
File.WriteAllText(reportPath, JsonSerializer.Serialize(report, jsonOptions), new UTF8Encoding(false));
var mdPath = Path.Combine(Path.GetDirectoryName(reportPath) ?? outDir, "section_brep_report.md");
File.WriteAllText(mdPath, BuildMarkdown(report), new UTF8Encoding(false));
return report;
}
static string ResolveImageOnlyModelPath(string requestedModelPath, string reportModelPath)
{
if (IsSolidWorksModelPath(requestedModelPath))
return Path.GetFullPath(requestedModelPath);
if (IsSolidWorksModelPath(reportModelPath))
return Path.GetFullPath(reportModelPath);
return Path.GetFullPath(FirstNonEmpty(reportModelPath, requestedModelPath));
}
static bool IsSolidWorksModelPath(string path)
{
if (string.IsNullOrWhiteSpace(path))
return false;
var extension = Path.GetExtension(path);
return extension.Equals(".SLDASM", StringComparison.OrdinalIgnoreCase) ||
extension.Equals(".SLDPRT", StringComparison.OrdinalIgnoreCase);
}
static string ResolveGroupIdFromOutputDirectory(string outputPath, string outputDir, IReadOnlyList<FaceHighlightExportGroup> groups)
{
if (string.IsNullOrWhiteSpace(outputPath))
return "";
var relative = Path.GetRelativePath(outputDir, outputPath).Replace('\\', '/');
foreach (var group in groups)
{
var directory = (group.Directory ?? "").Replace('\\', '/').Trim('/');
if (!string.IsNullOrWhiteSpace(directory) &&
relative.StartsWith(directory + "/", StringComparison.OrdinalIgnoreCase))
{
return group.GroupId ?? "";
}
}
return "";
}
static string FirstNonEmpty(params string?[] values)
{
foreach (var value in values)
{
if (!string.IsNullOrWhiteSpace(value))
return value.Trim();
}
return "";
}
static string BuildFaceRef(string? instanceName, int faceIndex) =>
!string.IsNullOrWhiteSpace(instanceName) && faceIndex > 0
? $"{instanceName}:face#{faceIndex}"
: "";
static string SanitizeFileName(string value)
{
var invalid = Path.GetInvalidFileNameChars().ToHashSet();
var chars = (value ?? "").Select(ch => invalid.Contains(ch) || char.IsControl(ch) ? '_' : ch).ToArray();
var result = new string(chars).Trim('_', '.', ' ');
return string.IsNullOrWhiteSpace(result) ? "unnamed" : result;
}
static int ParseFaceIndex(string faceRef)
{
var marker = faceRef.LastIndexOf("face#", StringComparison.OrdinalIgnoreCase);
if (marker < 0)
return 0;
var start = marker + "face#".Length;
var digits = new string(faceRef.Skip(start).TakeWhile(char.IsDigit).ToArray());
return int.TryParse(digits, NumberStyles.Integer, CultureInfo.InvariantCulture, out var index) ? index : 0;
}
static bool IsSolidWorksProcessAlive()
{
try
{
return Process.GetProcessesByName("SLDWORKS").Length > 0;
}
catch
{
return false;
}
}
static int GetSolidWorksAttachTimeoutSeconds(bool processAlive)
{
var defaultSeconds = processAlive ? 30 : 5;
var raw = System.Environment.GetEnvironmentVariable("SECTION_BREP_SW_ATTACH_TIMEOUT_SECONDS");
return int.TryParse(raw, out var configuredSeconds)
? Math.Clamp(configuredSeconds, 0, 300)
: defaultSeconds;
}
static ExtractorCommand ParseCommandLine(string[] args)
{
string? path = null;
string? outputDir = null;
string? imageDir = null;
string? faceHighlightPlanPath = null;
string? imageOnlyReportPath = null;
bool exportImages = false;
var imageViews = new List<string>();
var purchasedComponentHints = new List<string>();
var externalInterfaceRoots = new List<string>();
double[]? upAxis = null;
var positional = new List<string>();
for (int i = 0; i < args.Length; i++)
{
string arg = args[i];
if (arg.Equals("--output-dir", StringComparison.OrdinalIgnoreCase) && i + 1 < args.Length)
{
outputDir = Path.GetFullPath(args[++i]);
}
else if (arg.StartsWith("--output-dir=", StringComparison.OrdinalIgnoreCase))
{
outputDir = Path.GetFullPath(arg["--output-dir=".Length..]);
}
else if (arg.Equals("--export-images", StringComparison.OrdinalIgnoreCase))
{
exportImages = true;
}
else if (arg.Equals("--image-dir", StringComparison.OrdinalIgnoreCase) && i + 1 < args.Length)
{
imageDir = Path.GetFullPath(args[++i]);
exportImages = true;
}
else if (arg.StartsWith("--image-dir=", StringComparison.OrdinalIgnoreCase))
{
imageDir = Path.GetFullPath(arg["--image-dir=".Length..]);
exportImages = true;
}
else if (arg.Equals("--image-views", StringComparison.OrdinalIgnoreCase) && i + 1 < args.Length)
{
imageViews.AddRange(SplitCsv(args[++i]));
exportImages = true;
}
else if (arg.StartsWith("--image-views=", StringComparison.OrdinalIgnoreCase))
{
imageViews.AddRange(SplitCsv(arg["--image-views=".Length..]));
exportImages = true;
}
else if (arg.Equals("--face-highlight-plan", StringComparison.OrdinalIgnoreCase) && i + 1 < args.Length)
{
faceHighlightPlanPath = Path.GetFullPath(args[++i]);
exportImages = true;
}
else if (arg.StartsWith("--face-highlight-plan=", StringComparison.OrdinalIgnoreCase))
{
faceHighlightPlanPath = Path.GetFullPath(arg["--face-highlight-plan=".Length..]);
exportImages = true;
}
else if (arg.Equals("--image-only-from-report", StringComparison.OrdinalIgnoreCase) && i + 1 < args.Length)
{
imageOnlyReportPath = Path.GetFullPath(args[++i]);
exportImages = true;
}
else if (arg.StartsWith("--image-only-from-report=", StringComparison.OrdinalIgnoreCase))
{
imageOnlyReportPath = Path.GetFullPath(arg["--image-only-from-report=".Length..]);
exportImages = true;
}
else if (arg.Equals("--purchased-component-hint", StringComparison.OrdinalIgnoreCase) && i + 1 < args.Length)
{
purchasedComponentHints.Add(args[++i]);
}
else if (arg.StartsWith("--purchased-component-hint=", StringComparison.OrdinalIgnoreCase))
{
purchasedComponentHints.Add(arg["--purchased-component-hint=".Length..]);
}
else if (arg.Equals("--purchased-component-hints", StringComparison.OrdinalIgnoreCase) && i + 1 < args.Length)
{
purchasedComponentHints.AddRange(SplitCsv(args[++i]));
}
else if (arg.StartsWith("--purchased-component-hints=", StringComparison.OrdinalIgnoreCase))
{
purchasedComponentHints.AddRange(SplitCsv(arg["--purchased-component-hints=".Length..]));
}
else if (arg.Equals("--external-interface-root", StringComparison.OrdinalIgnoreCase) && i + 1 < args.Length)
{
externalInterfaceRoots.Add(args[++i]);
}
else if (arg.StartsWith("--external-interface-root=", StringComparison.OrdinalIgnoreCase))
{
externalInterfaceRoots.Add(arg["--external-interface-root=".Length..]);
}
else if (arg.Equals("--up-axis", StringComparison.OrdinalIgnoreCase) && i + 3 < args.Length)
{
upAxis = Vec.Normalize([double.Parse(args[++i]), double.Parse(args[++i]), double.Parse(args[++i])]);
}
else if (arg.StartsWith("--up-axis=", StringComparison.OrdinalIgnoreCase))
{
var parts = arg["--up-axis=".Length..].Split([',', ';', ' '], StringSplitOptions.RemoveEmptyEntries);
if (parts.Length != 3)
throw new ArgumentException("--up-axis requires three numbers.");
upAxis = Vec.Normalize([double.Parse(parts[0]), double.Parse(parts[1]), double.Parse(parts[2])]);
}
else if (arg.StartsWith("--", StringComparison.OrdinalIgnoreCase))
{
throw new ArgumentException($"Unknown option: {arg}");
}
else
{
positional.Add(arg);
}
}
if (positional.Count == 0 && string.IsNullOrWhiteSpace(faceHighlightPlanPath) && string.IsNullOrWhiteSpace(imageOnlyReportPath))
throw new ArgumentException("Model path is required.");
path = positional.Count > 0 ? positional[0] : FirstNonEmpty(imageOnlyReportPath, faceHighlightPlanPath)!;
var axis = positional.Count >= 4
? Vec.Normalize([double.Parse(positional[1]), double.Parse(positional[2]), double.Parse(positional[3])])
: new[] { 0.0, 1.0, 0.0 };
return new ExtractorCommand
{
Path = path,
Axis = axis,
UpAxis = upAxis,
OutputDir = outputDir,
ExportImages = exportImages,
ImageDir = imageDir,
ImageViews = exportImages
? (imageViews.Count > 0 ? imageViews.Distinct(StringComparer.OrdinalIgnoreCase).ToList() : ModelImageExporter.DefaultViewNames)
: imageViews.Distinct(StringComparer.OrdinalIgnoreCase).ToList(),
FaceHighlightPlanPath = faceHighlightPlanPath,
ImageOnlyReportPath = imageOnlyReportPath,
ExportSectionImages = false,
SectionImageViews = [],
AutoSectionImages = false,
SectionOffsetMm = 0,
SkipSectionBrep = true,
PurchasedComponentHints = purchasedComponentHints
.Select(hint => hint.Trim())
.Where(hint => !string.IsNullOrWhiteSpace(hint))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList(),
ExternalInterfaceRoots = externalInterfaceRoots
.Select(hint => hint.Trim())
.Where(hint => !string.IsNullOrWhiteSpace(hint))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList()
};
static IEnumerable<string> SplitCsv(string raw) =>
raw.Split([',', ';', '\n', '\r'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
static SectionBrepReport ExtractSectionBrep(
SldWorks sw,
string path,
double[] axis,
double[]? upAxis,
bool skipSectionBrep,
IReadOnlyList<string> purchasedComponentHints,
IReadOnlyList<string> externalInterfaceRoots,
Action<string, string> log)
{
int errors = 0;
int warnings = 0;
int docType = Path.GetExtension(path).Equals(".SLDASM", StringComparison.OrdinalIgnoreCase) ? AssemblyDocType : PartDoc;
var documentKind = docType == AssemblyDocType ? "assembly" : "part";
log("open_doc", path);
var doc = sw.OpenDoc6(path, docType, Silent | ReadOnly, "", ref errors, ref warnings) as ModelDoc2;
if (doc == null)
{
log("open_doc_failed", $"errors={errors}, warnings={warnings}");
return new SectionBrepReport
{
Ok = false,
Path = path,
Message = $"OpenDoc6 failed. errors={errors}, warnings={warnings}"
};
}
log("get_solidworks_utilities", $"errors={errors}, warnings={warnings}");
dynamic mathUtility = sw.GetMathUtility();
dynamic modeler = sw.GetModeler();
log("enumerate_components", documentKind);
var components = docType == AssemblyDocType && doc is AssemblyDoc assy
? EnumerateAssemblyComponents(assy, doc, mathUtility, purchasedComponentHints, externalInterfaceRoots, log)
: EnumeratePartComponents(doc, mathUtility, purchasedComponentHints);
log("enumerate_components_completed", $"component_count={components.Count}");
log("build_axis_groups", $"component_count={components.Count}");
var axisGroups = BuildAxisGroups(components);
var bestAxisGroup = axisGroups.OrderByDescending(g => g.ComponentCount).ThenByDescending(g => g.CylinderCount).FirstOrDefault();
if (bestAxisGroup == null)
{
log("build_axis_groups_failed", "no cylindrical axis group found");
return new SectionBrepReport
{
Ok = false,
Path = path,
Message = "No cylindrical axis group found."
};
}
var assemblyFrame = InferAssemblyFrame(components, bestAxisGroup.Axis, upAxis);
log("build_assembly_face_contacts", $"component_count={components.Count}");
var assemblyFaceContacts = BuildAssemblyFaceContacts(components, assemblyFrame, log);
if (externalInterfaceRoots.Count > 0)
{
var unfilteredCount = assemblyFaceContacts.Count;
assemblyFaceContacts = assemblyFaceContacts
.Where(contact => IsExternalInterfaceTargetRelation(
contact.ComponentA,
contact.ComponentPathA,
contact.ComponentB,
contact.ComponentPathB,
externalInterfaceRoots))
.ToList();
log("filter_external_interface_contacts", $"before={unfilteredCount}; after={assemblyFaceContacts.Count}; internal_and_unrelated_pairs=excluded");
}
var processLookup = BuildContactProcessLookup(assemblyFaceContacts);
var assemblyFaces = BuildAssemblyFaces(components, assemblyFrame, processLookup);
var assemblyComponents = BuildAssemblyComponentSummaries(components);
log("build_assembly_mates", $"component_count={assemblyComponents.Count}");
var assemblyMateRelations = docType == AssemblyDocType
? ExtractAssemblyMateRelations(doc, assemblyComponents, log)
: new List<AssemblyMateRelation>();
if (externalInterfaceRoots.Count > 0)
{
assemblyMateRelations = assemblyMateRelations
.Where(mate => IsExternalInterfaceTargetRelation(
mate.ComponentA,
mate.ComponentPathA,
mate.ComponentB,
mate.ComponentPathB,
externalInterfaceRoots))
.ToList();
}
var featureGraph = BuildMechanicalFeatureGraph(assemblyFaces, assemblyFaceContacts, assemblyFrame);
var componentBrepSource = docType == AssemblyDocType ? "assembly_component_brep_subset" : "part_brep_direct";
var componentEvidencePackages = BuildComponentEvidencePackages(assemblyComponents, assemblyFaces, assemblyFaceContacts, assemblyMateRelations, featureGraph, componentBrepSource);
var partImagePlan = BuildPartImagePlan(assemblyComponents, componentBrepSource);
var componentContextImagePlan = docType == AssemblyDocType
? BuildComponentContextImagePlan(assemblyComponents, componentEvidencePackages, assemblyFaceContacts, assemblyMateRelations)
: [];
log("skip_section_workflow", "section B-rep and section images are disabled; only assembly/component B-rep is extracted");
var sectionBodies = components.Select(component => new SectionBodyEvidence
{
ComponentName = component.Name,
Category = component.Category,
SectionEntities = []
}).ToList();
var report = new SectionBrepReport
{
Ok = true,
Path = path,
DocumentKind = documentKind,
Message = documentKind == "part"
? $"Part B-rep extracted from axis group {bestAxisGroup.GroupId}; part axis-section plan prepared when a cylindrical axis is available."
: $"Assembly/component B-rep extracted from axis group {bestAxisGroup.GroupId}; assembly section workflow disabled.",
AxisGroupId = bestAxisGroup.GroupId,
AssemblyFrame = assemblyFrame,
SectionPlane = new SectionPlaneEvidence(),
CanonicalSection = new CanonicalSectionBrep(),
Canonical2D = new Canonical2DStructureGraph(),
SketchGraph = new Canonical2DSketchGraph(),
AssemblyFaces = assemblyFaces,
AssemblyFaceContacts = assemblyFaceContacts,
AssemblyMates = assemblyMateRelations,
FeatureGraph = featureGraph,
SemanticFusionContract = BuildSemanticFusionContract(),
AssemblyComponents = assemblyComponents,
ComponentEvidencePackages = componentEvidencePackages,
PartImagePlan = partImagePlan,
ComponentContextImagePlan = componentContextImagePlan,
ExternalInterfaceRoots = externalInterfaceRoots.ToList(),
Components = sectionBodies
};
if (documentKind == "part")
report.SectionImagePlan = PartModelProcessing.BuildAxisSectionPlan(report);
return report;
}
static List<AssemblyComponentSummary> BuildAssemblyComponentSummaries(List<ComponentEvidence> components)
{
return components
.Select((component, index) => new AssemblyComponentSummary
{
Id = $"component_{index + 1}",
InstanceName = component.Name,
DisplayName = ComponentDisplayName(component.Name, component.Path),
FileName = string.IsNullOrWhiteSpace(component.Path) ? "" : Path.GetFileName(component.Path),
Path = component.Path,
Category = component.Category,
ReviewScope = ComponentReviewScope(component.Category, component.Name, component.Path),
BodyCount = component.Bodies.Count,
FaceCount = component.Faces.Count,
CylinderFaceCount = component.Cylinders.Count,
BBoxMm = component.BoundingBox?.ToArrayRounded()
?? ComponentBox(component)?.ToArrayRounded()
?? []
})
.OrderBy(c => c.InstanceName, StringComparer.OrdinalIgnoreCase)
.ToList();
}
static List<AssemblyMateRelation> ExtractAssemblyMateRelations(
ModelDoc2 doc,
List<AssemblyComponentSummary> components,
Action<string, string> log)
{
var relations = new List<AssemblyMateRelation>();
var byName = components.ToDictionary(c => c.InstanceName, StringComparer.OrdinalIgnoreCase);
var seenMates = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var seenRelations = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var scanned = 0;
var mateFeatures = 0;
foreach (var feature in WalkFeatures(Safe(() => doc.FirstFeature() as Feature, null)))
{
scanned++;
object specific = Safe<object>(() => feature.GetSpecificFeature2(), null!);
if (specific is not Mate2 mate)
continue;
mateFeatures++;
var mateKey = ComObjectIdentityKey(feature, $"{Safe(() => feature.GetTypeName2(), "")}|{Safe(() => feature.Name, "")}");
if (!seenMates.Add(mateKey))
continue;
var mateName = Safe(() => feature.Name, "");
var mateType = Safe(() => mate.Type, 0);
var alignment = Safe(() => mate.Alignment, 0);
var mateComponents = new Dictionary<string, AssemblyComponentSummary>(StringComparer.OrdinalIgnoreCase);
var entityCount = Safe(() => mate.GetMateEntityCount(), 0);
for (var i = 0; i < entityCount; i++)
{
object entityObj = Safe<object>(() => mate.MateEntity(i), null!);
if (entityObj is not MateEntity2 mateEntity)
continue;
var component = Safe(() => mateEntity.ReferenceComponent as Component2, null);
if (component == null)
continue;
var componentName = Safe(() => component.Name2, "");
var componentPath = Safe(() => component.GetPathName(), "");
var summary = ResolveComponent(components, byName, componentName, componentPath);
if (summary != null)
mateComponents[summary.InstanceName] = summary;
}
var linked = mateComponents.Values
.OrderBy(c => c.InstanceName, StringComparer.OrdinalIgnoreCase)
.ToList();
for (var i = 0; i < linked.Count; i++)
{
for (var j = i + 1; j < linked.Count; j++)
{
var a = linked[i];
var b = linked[j];
var relationKey = $"{mateName}|{mateType}|{alignment}|{a.InstanceName}|{b.InstanceName}";
if (!seenRelations.Add(relationKey))
continue;
relations.Add(new AssemblyMateRelation
{
Id = $"assembly_mate_{relations.Count + 1}",
MateName = mateName,
MateType = mateType,
MateTypeName = MateTypeName(mateType),
Alignment = alignment,
AlignmentName = MateAlignmentName(alignment),
ComponentA = a.InstanceName,
ComponentDisplayNameA = a.DisplayName,
ComponentPathA = a.Path,
ComponentFileNameA = a.FileName,
ComponentB = b.InstanceName,
ComponentDisplayNameB = b.DisplayName,
ComponentPathB = b.Path,
ComponentFileNameB = b.FileName,
EntityCount = entityCount,
Evidence = "solidworks_mate"
});
}
}
}
log("build_assembly_mates_completed", $"features={mateFeatures}; relations={relations.Count}; scanned_features={scanned}");
return relations
.OrderBy(m => m.MateName, StringComparer.OrdinalIgnoreCase)
.ThenBy(m => m.ComponentA, StringComparer.OrdinalIgnoreCase)
.ThenBy(m => m.ComponentB, StringComparer.OrdinalIgnoreCase)
.ToList();
}
static string ComponentReviewScope(string category, string name, string path)
{
if (IsStandardOrPurchasedComponent(category, name, path))
return "assembly_context_only";
return "part_design_required";
}
static bool IsStandardOrPurchasedComponent(string category, string name, string path)
{
var fileName = Path.GetFileNameWithoutExtension(path);
var nameAndFile = $"{name} {fileName}".ToLowerInvariant();
if (ContainsAny(nameAndFile, "\u5c0f\u81c2\u5173\u8282\u88c5\u914d2", "小臂关节装配2"))
return false;
if (ContainsAny(nameAndFile, "\u5c0f\u81c2\u5173\u8282\u88c5\u914d2", "小臂关节装配2"))
return true;
if (LooksLikeCustomAdapterPart(nameAndFile))
return false;
var s = $"{category} {nameAndFile}".ToLowerInvariant();
if (category is "bearing" or "purchased_motor" or "purchased_reducer" or "purchased_motor_reducer_assembly" or "user_purchased_component")
return true;
return ContainsAny(
s,
"gb", "t70", "t93", "t97", "t119", "t894",
"bearing", "bolt", "screw", "nut", "washer", "pin",
"motor", "servo", "gearbox", "reducer", "gear motor",
"\u8f74\u627f", "\u87ba\u9489", "\u87ba\u6813", "\u87ba\u6bcd", "\u57ab\u5708", "\u9500",
"\u7535\u673a", "\u9a6c\u8fbe", "\u4f3a\u670d", "\u51cf\u901f\u5668", "\u51cf\u901f\u673a", "\u51cf\u901f\u7535\u673a");
}
static bool ShouldSkipDetailedAssemblyBrep(string category, string name, string path)
{
var fileName = Path.GetFileNameWithoutExtension(path);
var nameAndFile = $"{name} {fileName}".ToLowerInvariant();
if (ContainsAny(nameAndFile, "\u5c0f\u81c2\u5173\u8282\u88c5\u914d2", "小臂关节装配2"))
return false;
if (LooksLikeCustomAdapterPart(nameAndFile))
return false;
var s = $"{category} {nameAndFile}".ToLowerInvariant();
if (category is "bearing" or "purchased_motor" or "purchased_reducer" or "purchased_motor_reducer_assembly" or "user_purchased_component")
return true;
return ContainsAny(
s,
"bearing", "motor", "servo", "gearbox", "reducer", "gear motor",
"\u8f74\u627f", "\u7535\u673a", "\u9a6c\u8fbe", "\u4f3a\u670d", "\u51cf\u901f\u5668", "\u51cf\u901f\u673a", "\u51cf\u901f\u7535\u673a");
}
static string ComponentDisplayName(string instanceName, string path)
{
var fileStem = string.IsNullOrWhiteSpace(path) ? "" : Path.GetFileNameWithoutExtension(path);
if (!string.IsNullOrWhiteSpace(fileStem))
return $"{instanceName} ({fileStem})";
return instanceName;
}
static List<ComponentEvidencePackage> BuildComponentEvidencePackages(
List<AssemblyComponentSummary> components,
List<AssemblyFaceEvidence> faces,
List<AssemblyFaceContactEvidence> contacts,
List<AssemblyMateRelation> mates,
MechanicalFeatureGraph featureGraph,
string brepSource)
{
var packages = new List<ComponentEvidencePackage>();
foreach (var component in components)
{
var ownedFaces = faces
.Where(f => SameComponent(f.ComponentName, component.InstanceName, f.ComponentPath, component.Path))
.OrderBy(f => f.FaceIndex)
.Select(FaceRef)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
var ownedFeatureIds = featureGraph.Features
.Where(f => SameComponent(f.ComponentName, component.InstanceName, f.ComponentPath, component.Path))
.Select(f => f.Id)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
var ownedContactIds = contacts
.Where(c =>
SameComponent(c.ComponentA, component.InstanceName, c.ComponentPathA, component.Path) ||
SameComponent(c.ComponentB, component.InstanceName, c.ComponentPathB, component.Path))
.Select(c => c.Id)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
var ownedMateIds = mates
.Where(m =>
SameComponent(m.ComponentA, component.InstanceName, m.ComponentPathA, component.Path) ||
SameComponent(m.ComponentB, component.InstanceName, m.ComponentPathB, component.Path))
.Select(m => m.Id)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
packages.Add(new ComponentEvidencePackage
{
ComponentId = component.Id,
InstanceName = component.InstanceName,
DisplayName = component.DisplayName,
FileName = component.FileName,
Path = component.Path,
Category = component.Category,
ReviewScope = component.ReviewScope,
BrepSource = brepSource,
ImageSource = component.ReviewScope.Equals("part_design_required", StringComparison.OrdinalIgnoreCase)
? "component_part_file_images_planned_and_assembly_component_highlight_images_planned"
: "assembly_images_only",
PartImagePlanId = component.ReviewScope.Equals("part_design_required", StringComparison.OrdinalIgnoreCase)
? PartImagePlanId(component.Id)
: "",
AssemblyContextImagePlanId = component.ReviewScope.Equals("part_design_required", StringComparison.OrdinalIgnoreCase)
? AssemblyContextImagePlanId(component.Id)
: "",
FaceRefs = ownedFaces,
FeatureIds = ownedFeatureIds,
ContactIds = ownedContactIds,
MateIds = ownedMateIds,
BodyCount = component.BodyCount,
FaceCount = component.FaceCount,
CylinderFaceCount = component.CylinderFaceCount,
BBoxMm = component.BBoxMm,
Rules =
[
"This package is the only B-rep/feature/contact/mate evidence package for this component instance.",
"Do not merge faces, features, or images from another component unless a relation/contact/mate id explicitly links them.",
"For part-design diagnosis, use this assembly-derived B-rep subset plus the matching PartImagePlan images."
]
});
}
return packages;
}
static List<PartImagePlanItem> BuildPartImagePlan(List<AssemblyComponentSummary> components, string brepSource)
{
var isDirectPart = brepSource.Equals(PartModelProcessing.DirectPartBrepSource, StringComparison.OrdinalIgnoreCase);
return components
.Where(c => c.ReviewScope.Equals("part_design_required", StringComparison.OrdinalIgnoreCase))
.Select(c => new PartImagePlanItem
{
Id = PartImagePlanId(c.Id),
ComponentId = c.Id,
InstanceName = c.InstanceName,
DisplayName = c.DisplayName,
ComponentFileName = c.FileName,
ComponentPath = c.Path,
BrepSource = brepSource,
ImageSource = isDirectPart ? "part_file_direct_views" : "component_part_file",
AssemblyContextImagePlanId = isDirectPart ? "" : AssemblyContextImagePlanId(c.Id),
AssemblyContextImageSource = isDirectPart ? "" : "assembly_component_highlight_images",
DoNotReextractBrep = !isDirectPart,
SuggestedViews = isDirectPart ? [] : ModelImageExporter.DefaultViewNames.ToList(),
SuggestedAssemblyContextViews = isDirectPart ? [] : ModelImageExporter.DefaultHighlightViewNames.ToList(),
HighlightInstruction = isDirectPart
? "SLDPRT input: do not export ordinary whole-part appearance views and do not use assembly hiding/isolation/component highlight. Export feature-group face highlight images tied to FeatureGraph ids first; export one unhighlighted axis section view only as supplemental internal-structure evidence."
: "When exporting assembly context images, select/highlight this whole component instance, not a feature or face.",
Purpose = isDirectPart
?
[
"Provide direct visual evidence for single-part diagnosis.",
"Keep geometry facts tied to the opened SLDPRT B-rep.",
"Use feature-group highlight images after B-rep feature grouping; each highlighted image must carry the matching feature id and face refs.",
"Use one unhighlighted axis section view only as supplemental internal/coaxial structure evidence.",
"Do not export ordinary whole-part appearance views for direct part diagnosis.",
"Do not use assembly-context hiding, component selection, or component highlight operations."
]
:
[
"Provide isolated visual evidence for nonstandard part-design diagnosis.",
"Keep all geometry facts tied to the component B-rep subset extracted from the assembly.",
"Use highlighted assembly context images to identify this component instance position and function in the assembly.",
"Use assembly contacts first to infer functional faces, then use part images to inspect local design and manufacturability."
]
})
.OrderBy(i => i.InstanceName, StringComparer.OrdinalIgnoreCase)
.ToList();
}
static string PartImagePlanId(string componentId) => $"part_image_{SanitizeId(componentId)}";
static string AssemblyContextImagePlanId(string componentId) => $"assembly_context_highlight_{SanitizeId(componentId)}";
static List<ComponentContextImagePlanItem> BuildComponentContextImagePlan(
List<AssemblyComponentSummary> components,
List<ComponentEvidencePackage> packages,
List<AssemblyFaceContactEvidence> contacts,
List<AssemblyMateRelation> mates)
{
var plans = new List<ComponentContextImagePlanItem>();
var byName = components.ToDictionary(c => c.InstanceName, StringComparer.OrdinalIgnoreCase);
var contactGraph = BuildComponentContactGraph(components, contacts);
var mateGraph = BuildComponentMateGraph(components, mates);
var relationGraph = MergeComponentRelationGraphs(contactGraph, mateGraph);
var assemblyBox = ComponentSummaryBBox(components);
var occlusionCache = new Dictionary<string, InternalOcclusionAnalysis>(StringComparer.OrdinalIgnoreCase);
var groupedInternal = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var targetSeeds = components
.Where(c => ShouldAnalyzeInternalCandidate(c, assemblyBox))
.OrderBy(c => c.InstanceName, StringComparer.OrdinalIgnoreCase)
.ToList();
foreach (var seed in targetSeeds)
{
if (groupedInternal.Contains(seed.InstanceName))
continue;
var seedAnalysis = GetInternalOcclusion(seed);
if (!seedAnalysis.IsInternal)
continue;
var internalMembers = new Dictionary<string, ComponentContextMember>(StringComparer.OrdinalIgnoreCase);
var boundaryMembers = new Dictionary<string, ComponentContextMember>(StringComparer.OrdinalIgnoreCase);
var memberAnalyses = new Dictionary<string, InternalOcclusionAnalysis>(StringComparer.OrdinalIgnoreCase);
var queue = new Queue<AssemblyComponentSummary>();
AddContextMember(internalMembers, seed, "internal_group_seed");
memberAnalyses[seed.InstanceName] = seedAnalysis;
queue.Enqueue(seed);
while (queue.Count > 0)
{
var current = queue.Dequeue();
if (!relationGraph.TryGetValue(current.InstanceName, out var neighborNames))
continue;
foreach (var neighborName in neighborNames)
{
if (!byName.TryGetValue(neighborName, out var other))
continue;
if (internalMembers.ContainsKey(other.InstanceName))
continue;
var otherAnalysis = GetInternalOcclusion(other);
if (otherAnalysis.IsInternal && !IsStandardOrPurchasedContext(other))
{
AddContextMember(internalMembers, other, "contact_internal_member");
memberAnalyses[other.InstanceName] = otherAnalysis;
queue.Enqueue(other);
}
else
{
AddContextMember(boundaryMembers, other, "contact_boundary_component");
}
}
}
var shown = internalMembers.Values
.OrderBy(m => m.Role == "internal_group_seed" ? 0 : 1)
.ThenBy(m => m.InstanceName, StringComparer.OrdinalIgnoreCase)
.ToList();
boundaryMembers.Clear();
AddDirectRelationBoundaryMembers(boundaryMembers, internalMembers, byName, relationGraph);
foreach (var target in shown)
groupedInternal.Add(target.InstanceName);
var groupId = $"internal_context_group_{plans.Count + 1}_{SanitizeId(seed.Id)}";
var boundaries = boundaryMembers.Values
.OrderBy(m => m.InstanceName, StringComparer.OrdinalIgnoreCase)
.ToList();
var analyses = memberAnalyses.Values
.OrderBy(a => a.InstanceName, StringComparer.OrdinalIgnoreCase)
.ToList();
foreach (var targetMember in shown)
{
if (!byName.TryGetValue(targetMember.InstanceName, out var targetComponent))
continue;
var targetAnalysis = GetInternalOcclusion(targetComponent);
plans.Add(new ComponentContextImagePlanItem
{
Id = $"{groupId}_target_{SanitizeId(targetComponent.Id)}",
GroupId = groupId,
TargetComponentId = targetComponent.Id,
TargetInstanceName = targetComponent.InstanceName,
TargetDisplayName = targetComponent.DisplayName,
TargetComponentPath = targetComponent.Path,
Strategy = "show_internal_component_group_hide_only_occlusion_blockers_and_highlight_target",
DisplayMode = "targeted_blocker_hiding_with_connected_context",
OcclusionMethod = "six_axis_bbox_projection_four_extreme_samples_first_hit_blockers",
RestoreRequired = true,
SaveModel = false,
SuggestedViews = ModelImageExporter.DefaultContextViewNames.ToList(),
ShowComponents = shown,
InternalMembers = shown,
BoundaryComponents = boundaries,
TargetOcclusion = targetAnalysis,
MemberOcclusionAnalyses = analyses,
HiddenPolicy = "hide_only_detected_occlusion_blockers_except_direct_contact_or_mate_neighbors",
Purpose =
[
"Expose this internal target component inside its geometrically internal component group.",
"Highlight the target component after hiding only detected geometric occlusion blockers.",
"Preserve every direct contact or mate neighbor of the internal component group as assembly-function context, even when it also appears in the blocker list.",
"Leave unrelated non-blocking components in their original visibility state.",
"Standard or purchased components are not diagnostic targets, but may appear as occlusion blockers or boundary context.",
"Use temporary component visibility changes only for image export.",
"Restore every component's original visibility immediately after each context image is saved.",
"Never save the SolidWorks model after temporary hiding."
]
});
}
InternalOcclusionAnalysis GetInternalOcclusion(AssemblyComponentSummary component)
{
if (!occlusionCache.TryGetValue(component.InstanceName, out var analysis))
{
analysis = AnalyzeInternalOcclusion(component, components);
occlusionCache[component.InstanceName] = analysis;
}
return analysis;
}
}
return plans;
}
static void AddDirectRelationBoundaryMembers(
Dictionary<string, ComponentContextMember> boundaryMembers,
Dictionary<string, ComponentContextMember> internalMembers,
Dictionary<string, AssemblyComponentSummary> byName,
Dictionary<string, List<string>> relationGraph)
{
var internalRoots = internalMembers.Keys
.OrderBy(name => name, StringComparer.OrdinalIgnoreCase)
.ToList();
var internalOrDescendantNames = byName.Keys
.Where(name => internalRoots.Any(root => IsSameOrChildComponentName(name, root)))
.OrderBy(name => name, StringComparer.OrdinalIgnoreCase)
.ToList();
foreach (var internalName in internalOrDescendantNames)
{
if (!relationGraph.TryGetValue(internalName, out var directNeighborNames))
continue;
foreach (var neighborName in directNeighborNames.OrderBy(name => name, StringComparer.OrdinalIgnoreCase))
{
if (!byName.TryGetValue(neighborName, out var neighbor))
continue;
if (internalRoots.Any(root => IsSameOrChildComponentName(neighbor.InstanceName, root)))
continue;
AddContextMember(boundaryMembers, neighbor, "direct_contact_or_mate_boundary_component");
}
}
}
static bool IsSameOrChildComponentName(string candidateName, string rootName)
{
if (candidateName.Equals(rootName, StringComparison.OrdinalIgnoreCase))
return true;
return candidateName.StartsWith(rootName + "/", StringComparison.OrdinalIgnoreCase);
}
static bool ShouldAnalyzeInternalCandidate(
AssemblyComponentSummary component,
Box? assemblyBox)
{
var wholeContextCandidate = IsWholeContextOcclusionCandidate(component);
if (!component.ReviewScope.Equals("part_design_required", StringComparison.OrdinalIgnoreCase) &&
!wholeContextCandidate)
return false;
if (IsLargeContextConnector(component) || !TryGetSummaryBox(component, out var box))
return false;
if (!wholeContextCandidate && assemblyBox.HasValue)
{
var assemblyVolume = BoxVolume(assemblyBox.Value);
var componentVolume = BoxVolume(box);
if (assemblyVolume > 1e-6 && componentVolume / assemblyVolume > 0.35)
return false;
}
return true;
}
static bool IsWholeContextOcclusionCandidate(AssemblyComponentSummary component)
{
if (component.Category.Equals("bearing", StringComparison.OrdinalIgnoreCase))
return true;
if (component.Category is "purchased_motor" or "purchased_reducer")
return true;
if (component.Category.Equals("purchased_motor_reducer_assembly", StringComparison.OrdinalIgnoreCase))
return !component.InstanceName.Contains('/');
return false;
}
static Dictionary<string, List<string>> BuildComponentContactGraph(
List<AssemblyComponentSummary> components,
List<AssemblyFaceContactEvidence> contacts)
{
var byName = components.ToDictionary(c => c.InstanceName, StringComparer.OrdinalIgnoreCase);
var graph = components.ToDictionary(
c => c.InstanceName,
_ => new HashSet<string>(StringComparer.OrdinalIgnoreCase),
StringComparer.OrdinalIgnoreCase);
foreach (var contact in contacts)
{
var a = ResolveComponent(components, byName, contact.ComponentA, contact.ComponentPathA);
var b = ResolveComponent(components, byName, contact.ComponentB, contact.ComponentPathB);
if (a == null || b == null || a.InstanceName.Equals(b.InstanceName, StringComparison.OrdinalIgnoreCase))
continue;
graph[a.InstanceName].Add(b.InstanceName);
graph[b.InstanceName].Add(a.InstanceName);
}
return graph.ToDictionary(
kvp => kvp.Key,
kvp => kvp.Value.OrderBy(v => v, StringComparer.OrdinalIgnoreCase).ToList(),
StringComparer.OrdinalIgnoreCase);
}
static Dictionary<string, List<string>> BuildComponentMateGraph(
List<AssemblyComponentSummary> components,
List<AssemblyMateRelation> mates)
{
var byName = components.ToDictionary(c => c.InstanceName, StringComparer.OrdinalIgnoreCase);
var graph = components.ToDictionary(
c => c.InstanceName,
_ => new HashSet<string>(StringComparer.OrdinalIgnoreCase),
StringComparer.OrdinalIgnoreCase);
foreach (var mate in mates)
{
var a = ResolveComponent(components, byName, mate.ComponentA, mate.ComponentPathA);
var b = ResolveComponent(components, byName, mate.ComponentB, mate.ComponentPathB);
if (a == null || b == null || a.InstanceName.Equals(b.InstanceName, StringComparison.OrdinalIgnoreCase))
continue;
graph[a.InstanceName].Add(b.InstanceName);
graph[b.InstanceName].Add(a.InstanceName);
}
return graph.ToDictionary(
kvp => kvp.Key,
kvp => kvp.Value.OrderBy(v => v, StringComparer.OrdinalIgnoreCase).ToList(),
StringComparer.OrdinalIgnoreCase);
}
static Dictionary<string, List<string>> MergeComponentRelationGraphs(
params Dictionary<string, List<string>>[] graphs)
{
var merged = new Dictionary<string, HashSet<string>>(StringComparer.OrdinalIgnoreCase);
foreach (var graph in graphs)
{
foreach (var (component, neighbors) in graph)
{
if (!merged.TryGetValue(component, out var set))
{
set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
merged[component] = set;
}
foreach (var neighbor in neighbors)
set.Add(neighbor);
}
}
return merged.ToDictionary(
kvp => kvp.Key,
kvp => kvp.Value.OrderBy(v => v, StringComparer.OrdinalIgnoreCase).ToList(),
StringComparer.OrdinalIgnoreCase);
}
static AssemblyComponentSummary? ResolveComponent(
List<AssemblyComponentSummary> components,
Dictionary<string, AssemblyComponentSummary> byName,
string name,
string path)
{
if (byName.TryGetValue(name, out var direct))
return direct;
return components.FirstOrDefault(c => SameComponent(c.InstanceName, name, c.Path, path));
}
static InternalOcclusionAnalysis AnalyzeInternalOcclusion(
AssemblyComponentSummary target,
List<AssemblyComponentSummary> components)
{
var analysis = new InternalOcclusionAnalysis
{
ComponentId = target.Id,
InstanceName = target.InstanceName,
DisplayName = target.DisplayName,
Method = "six_axis_bbox_projection_four_extreme_samples_first_hit_blockers",
Decision = "insufficient_bbox"
};
if (!TryGetSummaryBox(target, out var targetBox))
return analysis;
var occludedCount = 0;
var openCount = 0;
foreach (var direction in OcclusionDirections())
{
var directionAnalysis = AnalyzeOcclusionDirection(target, targetBox, components, direction);
analysis.Directions.Add(directionAnalysis);
if (directionAnalysis.Occluded)
occludedCount++;
else
openCount++;
if (occludedCount >= 4)
{
analysis.Decision = "occluded_direction_count_reached_4";
analysis.EarlyStop = true;
break;
}
if (openCount >= 3)
{
analysis.Decision = "open_direction_count_reached_3";
analysis.EarlyStop = true;
break;
}
}
analysis.OccludedDirectionCount = occludedCount;
analysis.OpenDirectionCount = openCount;
analysis.IsInternal = occludedCount >= 4;
if (!analysis.EarlyStop)
analysis.Decision = analysis.IsInternal ? "occluded_direction_count_gt_3" : "occluded_direction_count_not_gt_3";
return analysis;
}
static InternalOcclusionDirectionAnalysis AnalyzeOcclusionDirection(
AssemblyComponentSummary target,
Box targetBox,
List<AssemblyComponentSummary> components,
OcclusionDirectionSpec direction)
{
var targetProjection = ProjectBoxOnAxes(targetBox, direction.UAxis, direction.VAxis);
var samples = ExtremeProjectionSamples(targetProjection);
var blockers = new Dictionary<string, AssemblyComponentSummary>(StringComparer.OrdinalIgnoreCase);
foreach (var sample in samples)
{
var blocker = FindFirstHitBlocker(target, targetBox, components, direction, sample.U, sample.V);
if (blocker != null)
blockers[blocker.InstanceName] = blocker;
}
var blockerRects = blockers.Values
.Select(c => TryGetSummaryBox(c, out var box)
? (ProjectionRect?)ProjectBoxOnAxes(box, direction.UAxis, direction.VAxis)
: null)
.Where(r => r.HasValue)
.Select(r => r!.Value)
.ToList();
var targetArea = targetProjection.AreaMm2;
var blockedArea = targetArea <= 1e-9
? 0.0
: UnionAreaClipped(blockerRects, targetProjection);
var ratio = targetArea <= 1e-9 ? 0.0 : blockedArea / targetArea;
return new InternalOcclusionDirectionAnalysis
{
Direction = direction.Name,
Occluded = ratio > 0.5,
OcclusionRatio = Math.Round(ratio, 3),
FirstHitComponents = blockers.Values
.OrderBy(c => c.InstanceName, StringComparer.OrdinalIgnoreCase)
.Select(c => new OcclusionBlocker
{
ComponentId = c.Id,
InstanceName = c.InstanceName,
DisplayName = c.DisplayName,
ComponentPath = c.Path
})
.ToList()
};
}
static AssemblyComponentSummary? FindFirstHitBlocker(
AssemblyComponentSummary target,
Box targetBox,
List<AssemblyComponentSummary> components,
OcclusionDirectionSpec direction,
double u,
double v)
{
const double toleranceMm = 0.5;
AssemblyComponentSummary? best = null;
double bestGap = double.PositiveInfinity;
foreach (var candidate in components)
{
if (candidate.InstanceName.Equals(target.InstanceName, StringComparison.OrdinalIgnoreCase))
continue;
if (!TryGetSummaryBox(candidate, out var candidateBox))
continue;
var candidateProjection = ProjectBoxOnAxes(candidateBox, direction.UAxis, direction.VAxis);
if (!ProjectionContains(candidateProjection, u, v, toleranceMm))
continue;
var gap = DirectionalOutsideGap(targetBox, candidateBox, direction.Axis, direction.Sign, toleranceMm);
if (!gap.HasValue)
continue;
var candidateVolume = BoxVolume(candidateBox);
var bestVolume = best == null || !TryGetSummaryBox(best, out var bestBox) ? double.PositiveInfinity : BoxVolume(bestBox);
if (gap.Value < bestGap - toleranceMm ||
(Math.Abs(gap.Value - bestGap) <= toleranceMm && candidateVolume < bestVolume))
{
best = candidate;
bestGap = gap.Value;
}
}
return best;
}
static IReadOnlyList<OcclusionDirectionSpec> OcclusionDirections() =>
[
new("+X", 0, 1.0, 1, 2),
new("-X", 0, -1.0, 1, 2),
new("+Y", 1, 1.0, 0, 2),
new("-Y", 1, -1.0, 0, 2),
new("+Z", 2, 1.0, 0, 1),
new("-Z", 2, -1.0, 0, 1)
];
static ProjectionRect ProjectBoxOnAxes(Box box, int uAxis, int vAxis)
{
var uMin = BoxAxisMin(box, uAxis);
var uMax = BoxAxisMax(box, uAxis);
var vMin = BoxAxisMin(box, vAxis);
var vMax = BoxAxisMax(box, vAxis);
return new ProjectionRect(uMin, uMax, vMin, vMax);
}
static List<(double U, double V)> ExtremeProjectionSamples(ProjectionRect rect)
{
var uMid = (rect.UMin + rect.UMax) / 2.0;
var vMid = (rect.VMin + rect.VMax) / 2.0;
return
[
(uMid, rect.VMax),
(uMid, rect.VMin),
(rect.UMin, vMid),
(rect.UMax, vMid)
];
}
static bool ProjectionContains(ProjectionRect rect, double u, double v, double toleranceMm) =>
u >= rect.UMin - toleranceMm && u <= rect.UMax + toleranceMm &&
v >= rect.VMin - toleranceMm && v <= rect.VMax + toleranceMm;
static double? DirectionalOutsideGap(Box target, Box candidate, int axis, double sign, double toleranceMm)
{
var targetMin = BoxAxisMin(target, axis);
var targetMax = BoxAxisMax(target, axis);
var candidateMin = BoxAxisMin(candidate, axis);
var candidateMax = BoxAxisMax(candidate, axis);
if (sign >= 0)
{
if (candidateMax <= targetMax + toleranceMm)
return null;
return Math.Max(0.0, candidateMin - targetMax);
}
if (candidateMin >= targetMin - toleranceMm)
return null;
return Math.Max(0.0, targetMin - candidateMax);
}
static double UnionAreaClipped(List<ProjectionRect> rects, ProjectionRect clip)
{
var clipped = rects
.Select(r => IntersectProjectionRect(r, clip))
.Where(r => r.HasValue && r.Value.AreaMm2 > 1e-9)
.Select(r => r!.Value)
.ToList();
if (clipped.Count == 0)
return 0.0;
var uBreaks = clipped
.SelectMany(r => new[] { r.UMin, r.UMax })
.Distinct()
.OrderBy(v => v)
.ToList();
double area = 0.0;
for (int i = 0; i < uBreaks.Count - 1; i++)
{
var u0 = uBreaks[i];
var u1 = uBreaks[i + 1];
if (u1 <= u0)
continue;
var active = clipped
.Where(r => r.UMin < u1 && r.UMax > u0)
.Select(r => (r.VMin, r.VMax))
.OrderBy(r => r.VMin)
.ToList();
if (active.Count == 0)
continue;
double coveredV = 0.0;
var currentMin = active[0].VMin;
var currentMax = active[0].VMax;
foreach (var interval in active.Skip(1))
{
if (interval.VMin <= currentMax)
{
currentMax = Math.Max(currentMax, interval.VMax);
}
else
{
coveredV += currentMax - currentMin;
currentMin = interval.VMin;
currentMax = interval.VMax;
}
}
coveredV += currentMax - currentMin;
area += (u1 - u0) * coveredV;
}
return area;
}
static ProjectionRect? IntersectProjectionRect(ProjectionRect a, ProjectionRect b)
{
var uMin = Math.Max(a.UMin, b.UMin);
var uMax = Math.Min(a.UMax, b.UMax);
var vMin = Math.Max(a.VMin, b.VMin);
var vMax = Math.Min(a.VMax, b.VMax);
return uMax <= uMin || vMax <= vMin ? null : new ProjectionRect(uMin, uMax, vMin, vMax);
}
static Box? ComponentSummaryBBox(List<AssemblyComponentSummary> components)
{
var boxes = components
.Where(c => TryGetSummaryBox(c, out _))
.Select(c =>
{
TryGetSummaryBox(c, out var box);
return box;
})
.ToList();
if (boxes.Count == 0)
return null;
return new Box(
boxes.Min(b => b.XMin),
boxes.Min(b => b.YMin),
boxes.Min(b => b.ZMin),
boxes.Max(b => b.XMax),
boxes.Max(b => b.YMax),
boxes.Max(b => b.ZMax));
}
static bool TryGetSummaryBox(AssemblyComponentSummary component, out Box box)
{
box = default;
if (component.BBoxMm.Count() < 6)
return false;
box = Box.FromArray(component.BBoxMm.Take(6).ToArray());
return BoxVolume(box) > 1e-9;
}
static double BoxVolume(Box box) =>
Math.Max(0.0, box.XMax - box.XMin) *
Math.Max(0.0, box.YMax - box.YMin) *
Math.Max(0.0, box.ZMax - box.ZMin);
static double BoxAxisMin(Box box, int axis) => axis switch
{
0 => box.XMin,
1 => box.YMin,
_ => box.ZMin
};
static double BoxAxisMax(Box box, int axis) => axis switch
{
0 => box.XMax,
1 => box.YMax,
_ => box.ZMax
};
static bool IsStandardOrPurchasedContext(AssemblyComponentSummary component) =>
component.ReviewScope.Equals("assembly_context_only", StringComparison.OrdinalIgnoreCase) ||
IsStandardOrPurchasedComponent(component.Category, component.InstanceName, component.Path);
static void AddContextMember(Dictionary<string, ComponentContextMember> members, AssemblyComponentSummary component, string role)
{
members[component.InstanceName] = new ComponentContextMember
{
ComponentId = component.Id,
InstanceName = component.InstanceName,
DisplayName = component.DisplayName,
ComponentPath = component.Path,
Role = role
};
}
static bool IsLargeContextConnector(AssemblyComponentSummary component)
{
return component.Category is "housing" or "inspection_cover";
}
static bool SameComponent(string nameA, string nameB, string pathA, string pathB)
{
if (!string.IsNullOrWhiteSpace(pathA) &&
!string.IsNullOrWhiteSpace(pathB) &&
pathA.Equals(pathB, StringComparison.OrdinalIgnoreCase) &&
nameA.Equals(nameB, StringComparison.OrdinalIgnoreCase))
return true;
return nameA.Equals(nameB, StringComparison.OrdinalIgnoreCase);
}
static MechanicalFeatureGraph BuildMechanicalFeatureGraph(
List<AssemblyFaceEvidence> faces,
List<AssemblyFaceContactEvidence> contacts,
AssemblyFrameEvidence frame)
{
var graph = new MechanicalFeatureGraph();
var cylinders = faces
.Where(f => f.FaceKind.Equals("cylinder", StringComparison.OrdinalIgnoreCase) && f.RadiusMm > 0 && f.Axis.Length >= 3)
.ToList();
var planes = faces
.Where(f => f.FaceKind.Equals("plane", StringComparison.OrdinalIgnoreCase))
.ToList();
var maxRadius = cylinders.Count == 0 ? 0 : cylinders.Max(f => f.RadiusMm);
var holeLimit = Math.Max(12.0, maxRadius * 0.35);
int index = 0;
foreach (var holeGroup in BuildHoleFeatureGroups(cylinders.Where(f => f.RadiusMm <= holeLimit).ToList(), frame))
{
index++;
var members = holeGroup.Members.OrderBy(f => f.FaceIndex).ToList();
var representative = members.OrderByDescending(f => f.AreaMm2).First();
var entryCandidates = members
.SelectMany(m => FindHoleEntryFaceCandidates(m, planes))
.GroupBy(FaceRef, StringComparer.OrdinalIgnoreCase)
.Select(g => g.First())
.Take(8)
.ToList();
var geometry = new Dictionary<string, object?>
{
["radius_mm"] = Math.Round(representative.RadiusMm, 4),
["diameter_mm"] = Math.Round(representative.RadiusMm * 2.0, 4),
["axis"] = Vec.Round(representative.Axis),
["center_points_mm"] = members.Select(m => Vec.Round(m.CenterMm)).ToList(),
["entry_face_candidate_refs"] = entryCandidates.Select(FaceRef).ToList(),
["member_count"] = members.Count,
["pattern_type"] = holeGroup.PatternType,
["grouping_basis"] = holeGroup.GroupingBasis
};
if (holeGroup.PatternNormal.Length >= 3)
geometry["pattern_plane_normal"] = Vec.Round(holeGroup.PatternNormal);
if (holeGroup.PatternPlaneTmm.HasValue)
geometry["pattern_plane_t_mm"] = Math.Round(holeGroup.PatternPlaneTmm.Value, 3);
if (holeGroup.PatternCenterMm.Length >= 3)
geometry["pattern_center_mm"] = Vec.Round(holeGroup.PatternCenterMm);
if (holeGroup.PatternRadiusMm > 0)
geometry["pattern_radius_mm"] = Math.Round(holeGroup.PatternRadiusMm, 3);
if (holeGroup.SpacingMm > 0)
geometry["spacing_mm"] = Math.Round(holeGroup.SpacingMm, 3);
graph.Features.Add(new MechanicalFeature
{
Id = $"hole_group_{index}",
Type = members.Count > 1 ? "hole_group" : "hole_feature",
ComponentName = representative.ComponentName,
ComponentDisplayName = representative.ComponentDisplayName,
ComponentPath = representative.ComponentPath,
ComponentFileName = representative.ComponentFileName,
FaceRefs = members.Select(FaceRef).ToList(),
Confidence = 0.74,
Source = "brep_cylinder_grouping",
PossibleFunctions = ["fastener_hole_candidate", "port_or_pin_hole_candidate"],
VisualObservationNeeds =
[
"confirm_hole_entry_location_in_model_images",
"confirm_whether_entry_has_flat_pad_or_spotface",
"confirm_whether_hole_is_internal_external_or_edge_near"
],
RequiredBrepChecks =
[
"entry_face_normal_vs_hole_axis_angle",
"hole_throughness_or_blind_depth",
"minimum_edge_clearance_to_neighbor_boundaries",
"spotface_or_counterbore_detection",
"hole_pattern_grouping_by_center_parameters"
],
Geometry = geometry
});
}
int cylIndex = 0;
foreach (var group in cylinders
.Where(f => f.RadiusMm > holeLimit)
.GroupBy(f => $"{f.ComponentName}:{AxisKey(f.Axis)}:{Math.Round(f.RadiusMm, 1)}", StringComparer.OrdinalIgnoreCase)
.OrderByDescending(g => g.Sum(f => f.AreaMm2)))
{
cylIndex++;
var members = group.OrderBy(f => f.FaceIndex).ToList();
var representative = members.OrderByDescending(f => f.AreaMm2).First();
bool shaftAxis = IsParallel(representative.Axis, frame.ShaftAxisMm, 0.85);
graph.Features.Add(new MechanicalFeature
{
Id = $"cylindrical_surface_group_{cylIndex}",
Type = shaftAxis ? "main_axis_cylindrical_surface_group" : "cylindrical_surface_group",
ComponentName = representative.ComponentName,
ComponentDisplayName = representative.ComponentDisplayName,
ComponentPath = representative.ComponentPath,
ComponentFileName = representative.ComponentFileName,
FaceRefs = members.Select(FaceRef).ToList(),
Confidence = 0.7,
Source = "brep_cylinder_grouping",
PossibleFunctions = shaftAxis
? ["bearing_or_hinge_seat_candidate", "internal_cylindrical_surface_candidate", "shell_arc_candidate"]
: ["outer_shell_candidate", "boss_or_side_cylindrical_surface_candidate"],
VisualObservationNeeds =
[
"confirm_internal_or_external_side_from_images",
"confirm_if_surface_looks_like_precision_seat_or_cast_shell"
],
RequiredBrepChecks =
[
"internal_external_classification",
"tool_access_direction_and_surrounding_wall_clearance",
"surface_continuity_and_interruption"
],
Geometry =
{
["radius_mm"] = Math.Round(representative.RadiusMm, 4),
["diameter_mm"] = Math.Round(representative.RadiusMm * 2.0, 4),
["axis"] = Vec.Round(representative.Axis),
["axis_point_mm"] = Vec.Round(representative.AxisPointMm),
["total_area_mm2"] = Math.Round(members.Sum(m => m.AreaMm2), 3),
["member_count"] = members.Count
}
});
}
int planeIndex = 0;
foreach (var plane in planes.OrderByDescending(f => f.AreaMm2).Take(10))
{
planeIndex++;
graph.Features.Add(new MechanicalFeature
{
Id = $"planar_surface_candidate_{planeIndex}",
Type = "planar_pad_step_or_face_candidate",
ComponentName = plane.ComponentName,
ComponentDisplayName = plane.ComponentDisplayName,
ComponentPath = plane.ComponentPath,
ComponentFileName = plane.ComponentFileName,
FaceRefs = [FaceRef(plane)],
Confidence = 0.62,
Source = "brep_plane_face",
PossibleFunctions = ["mounting_pad_candidate", "step_face_candidate", "datum_or_machined_face_candidate"],
VisualObservationNeeds =
[
"confirm_whether_face_is_visible_as_real_plane_or_projection",
"confirm_if_face_contacts_other_parts_or appears as mounting pad"
],
RequiredBrepChecks =
[
"adjacency_to_nonmachined_regions",
"contact_or_fit_evidence",
"local_height_difference_to_surrounding_faces"
],
Geometry =
{
["area_mm2"] = Math.Round(plane.AreaMm2, 3),
["normal"] = Vec.Round(plane.Normal),
["center_mm"] = Vec.Round(plane.CenterMm),
["bbox_mm"] = Vec.Round(plane.BBoxMm)
}
});
}
int complexIndex = 0;
foreach (var face in faces
.Where(f => !f.FaceKind.Equals("plane", StringComparison.OrdinalIgnoreCase) &&
!f.FaceKind.Equals("cylinder", StringComparison.OrdinalIgnoreCase))
.Where(f => f.AreaMm2 >= 5.0)
.OrderByDescending(f => f.AreaMm2)
.Take(24))
{
complexIndex++;
graph.Features.Add(new MechanicalFeature
{
Id = $"complex_unknown_surface_candidate_{complexIndex}",
Type = "complex_unknown_surface_candidate",
ComponentName = face.ComponentName,
ComponentDisplayName = face.ComponentDisplayName,
ComponentPath = face.ComponentPath,
ComponentFileName = face.ComponentFileName,
FaceRefs = [FaceRef(face)],
Confidence = 0.58,
Source = "brep_other_face",
PossibleFunctions =
[
"transition_surface_candidate",
"partial_bore_or_non_cylindrical_hole_boundary_candidate",
"machining_risk_surface_candidate"
],
VisualObservationNeeds =
[
"highlight_face_to_identify_real_surface_shape",
"confirm_if_surface_is_chamfer_fillet_cone_partial_bore_or_freeform_transition",
"confirm_tool_access_and_adjacent_boundary_from_best_view"
],
RequiredBrepChecks =
[
"unknown_surface_visual_grounding",
"edge_loop_or_arc_boundary_extraction_needed",
"adjacent_surface_transition_and_tool_access_check"
],
Geometry =
{
["face_kind"] = face.FaceKind,
["area_mm2"] = Math.Round(face.AreaMm2, 3),
["center_mm"] = Vec.Round(face.CenterMm),
["bbox_mm"] = Vec.Round(face.BBoxMm),
["orientation_role"] = face.OrientationRole,
["functional_role"] = face.FunctionalRole,
["surface_process_role"] = face.SurfaceProcessRole
}
});
}
int contactIndex = 0;
foreach (var contact in contacts)
{
contactIndex++;
graph.Features.Add(new MechanicalFeature
{
Id = $"assembly_contact_feature_{contactIndex}",
Type = contact.ContactKind,
ComponentName = $"{contact.ComponentA}|{contact.ComponentB}",
ComponentDisplayName = $"{contact.ComponentDisplayNameA}|{contact.ComponentDisplayNameB}",
ComponentPath = $"{contact.ComponentPathA}|{contact.ComponentPathB}",
ComponentFileName = $"{contact.ComponentFileNameA}|{contact.ComponentFileNameB}",
FaceRefs =
[
$"{contact.ComponentA}:face#{contact.FaceA}",
$"{contact.ComponentB}:face#{contact.FaceB}"
],
Confidence = contact.Confidence,
Source = "brep_assembly_face_contact",
PossibleFunctions = ["fit_contact_candidate", "locating_or_support_contact_candidate"],
VisualObservationNeeds =
[
"confirm_external_accessibility_around_contact",
"confirm_relation_context_in_standard_or_section_images"
],
RequiredBrepChecks =
[
"clearance_or_gap_near_contact",
"contact_overlap_area",
"coaxiality_or_face_parallelism"
],
Geometry =
{
["gap_mm"] = contact.GapMm,
["mate_role"] = contact.MateRole,
["component_a"] = contact.ComponentA,
["component_b"] = contact.ComponentB,
["component_a_file"] = contact.ComponentFileNameA,
["component_b_file"] = contact.ComponentFileNameB,
["center_a_mm"] = Vec.Round(contact.CenterA),
["center_b_mm"] = Vec.Round(contact.CenterB),
["axis_a"] = Vec.Round(contact.AxisA),
["axis_b"] = Vec.Round(contact.AxisB)
}
});
}
graph.FusionGuidance =
[
"Image observations should propose global layout, accessibility, internal/external side, and mechanical intent.",
"B-rep features should confirm face type, radius, axis, area, centers, candidate entry faces, and contact facts.",
"A diagnosis should cite both image observations and feature ids when both are available.",
"If image and B-rep evidence suggest different meanings, keep the issue as uncertain and request an additional standard/context image or computed B-rep check."
];
return graph;
}
static SemanticFusionContract BuildSemanticFusionContract()
{
return new SemanticFusionContract
{
Schema = "mechanical_semantic_fusion_contract_v2",
Pipeline =
[
"Stage 1: extract assembly B-rep once. For an assembly, every component's B-rep facts must come from its assembly component instance, including assembly transforms and contacts.",
"Stage 2: build ComponentEvidencePackages. Keep each component's faces, features, contacts, and planned images separated by SolidWorks instance name and file path.",
"Stage 3: export baseline assembly images, component highlight images, component part-file images, physical-interface/contact highlight images, and internal component group context images. Section images are disabled.",
"Stage 4: VLM analyzes physical-interface/contact highlight images together with B-rep contacts, SolidWorks mates, adjacent component context, and component function profiles. Export standalone per-interface contact function descriptions under diagnostic_context/interface_function_descriptions.json.",
"Stage 5: AI first builds assembly functional context: infer each component's role in the assembly from SolidWorks mates, B-rep face contacts, physical-interface image conclusions, coaxial/flush/parallel relations, component positions, and images. Each component-function conclusion must preserve the part-to-part mating/contact relations that support it.",
"Stage 6: AI then diagnoses assembly-level issues: contacts, fits, locating, disassembly, interference, accessibility, support, sealing, lubrication, and motion relations.",
"Stage 7: for every component with review_scope=part_design_required, export or use its part-file images according to PartImagePlan, but do not re-extract or mix part B-rep. Diagnose part-design issues using the assembly-derived component B-rep subset plus that component's images and the inherited assembly functional context for that component.",
"Stage 8: components with review_scope=assembly_context_only are standard or purchased context, including bearings, fasteners, motors, servos, reducers, and gearboxes. Do not run nonstandard part-structure taboo checks on them unless the task is standard-part selection/calculation; keep them as identified purchased components, occlusion blockers, contact boundaries, and assembly-function context.",
"Stage 9: for geometrically internal nonstandard components, use ComponentContextImagePlan internal groups. These groups are generated before AI function understanding by six-axis BBox occlusion analysis and contact/mate relation expansion; context images hide only detected blockers, preserve direct contact/mate neighbors, and leave unrelated non-blockers unchanged.",
"Stage 10: AI generates a knowledge-blind global mechanical semantic graph: parts, feature groups, contacts, fits, surface roles, manufacturing candidates, function candidates, component_function_profiles, interface_function_profiles, and uncertainty. Always preserve SolidWorks component names and file names.",
"Stage 11: AI generates LocalSemanticUnit objects from the global semantic graph. Split units by coverage anchors: part, feature, interface, and functional_group. Every part-level or feature-level unit for an assembly component must carry the inherited assembly_function_context for that component in fact_bundle.",
"Stage 12: AI writes semantic_coverage_audit to prove which part/design components, feature anchors, interface relations, and functional groups are covered or intentionally excluded.",
"Stage 13: retrieve knowledge rules by LocalSemanticUnit.primary_view/secondary_views and retrieval_sentence. Do not attach rule ids before retrieval.",
"Stage 14: load detailed verification_rule only for retrieved candidates and judge with LocalSemanticUnit.fact_bundle plus global semantic graph evidence.",
"Stage 15: if a candidate rule cannot be verified because extractable facts are missing, request additional standard/context images or computed B-rep checks instead of guessing."
],
FeatureTypes =
[
"hole_feature",
"hole_group",
"main_axis_cylindrical_surface_group",
"cylindrical_surface_group",
"planar_pad_step_or_face_candidate",
"assembly_contact_feature"
],
SemanticTypes =
[
new SemanticTypeSpec
{
Name = "part_identity",
AppliesTo = "part_or_component",
Purpose = "Identify likely part class and role, such as housing, shell, cover, bracket, sleeve, shaft, or support.",
TypicalTargets = ["component"]
},
new SemanticTypeSpec
{
Name = "structural_semantic",
AppliesTo = "feature",
Purpose = "Describe what the feature physically is: hole group, internal cylindrical seat, shell wall, boss, pad, step, rib, cavity, groove, datum face.",
TypicalTargets = ["hole_group", "cylindrical_surface_group", "planar_pad_step_or_face_candidate"]
},
new SemanticTypeSpec
{
Name = "functional_semantic",
AppliesTo = "feature_or_relation",
Purpose = "Describe likely mechanical function: locating, supporting, fastening, bearing/hinge seating, sealing, guiding, avoiding, accessing, or assembly positioning.",
TypicalTargets = ["hole_group", "main_axis_cylindrical_surface_group", "assembly_contact_feature"]
},
new SemanticTypeSpec
{
Name = "manufacturing_semantic",
AppliesTo = "feature",
Purpose = "Describe manufacturing interpretation: machined face candidate, unmachined candidate, drilling entry check, internal surface machining, multi-setup candidate, tool access candidate.",
TypicalTargets = ["hole_group", "main_axis_cylindrical_surface_group", "planar_pad_step_or_face_candidate"]
},
new SemanticTypeSpec
{
Name = "assembly_semantic",
AppliesTo = "feature_or_component_relation",
Purpose = "Describe contact, fit, coaxiality, locating, support, clearance, disassembly path, or external accessibility.",
TypicalTargets = ["assembly_contact_feature", "hole_group", "cylindrical_surface_group"]
},
new SemanticTypeSpec
{
Name = "risk_semantic",
AppliesTo = "feature_or_local_subgraph",
Purpose = "Describe possible design/manufacturing risk only as an uncertainty or candidate, without final judgement.",
TypicalTargets = ["internal_surface_machining_candidate", "hole_entry_risk_candidate", "tool_access_blocked_candidate", "multi_setup_candidate"]
}
],
RetrievalViews =
[
new RetrievalViewSpec { Id = "system_function_condition", Name = "整机功能与使用条件视角", Focus = "整体功能、工况、输入输出、总体布局和方案约束。" },
new RetrievalViewSpec { Id = "part_local_shape", Name = "零件局部形状视角", Focus = "局部几何、壁厚、圆角、倒角、开口、尖角和复杂区域。" },
new RetrievalViewSpec { Id = "surface_role_boundary", Name = "表面角色与边界视角", Focus = "加工面、非加工面、接触面、定位面、密封面和边界分离。" },
new RetrievalViewSpec { Id = "feature_pattern", Name = "孔槽凸台筋板等特征视角", Focus = "孔、槽、凸台、凹台、筋板、台阶、止口和沟槽等特征组合。" },
new RetrievalViewSpec { Id = "assembly_contact_fit", Name = "装配接触与配合视角", Focus = "零件之间的接触、配合、间隙、同轴、支承和相对位置。" },
new RetrievalViewSpec { Id = "locating_limit_datum", Name = "定位限位与基准视角", Focus = "定位、限位、基准、测量基面、对中面、装夹面和自由度约束。" },
new RetrievalViewSpec { Id = "disassembly_maintenance_access", Name = "拆装维修与可达性视角", Focus = "装配路径、拆卸方向、工具伸入空间、外部可达性和遮挡。" },
new RetrievalViewSpec { Id = "tool_path_access", Name = "刀具路径与加工可达性视角", Focus = "进刀、退刀、钻削入口、封闭槽、深腔、遮挡壁、刀具半径和加工振动。" },
new RetrievalViewSpec { Id = "operation_process_economy", Name = "加工工序与工艺经济性视角", Focus = "一次走刀、批量加工、换刀数量、多次装夹、组合加工和工序合并。" },
new RetrievalViewSpec { Id = "blank_forming_material", Name = "毛坯成形与材料工艺视角", Focus = "铸造、锻造、焊接、冲压、机加工毛坯、成形误差和余量。" },
new RetrievalViewSpec { Id = "load_strength_stiffness", Name = "载荷强度刚度与应力视角", Focus = "受力路径、强度、刚度、疲劳、应力集中、薄弱截面和变形。" },
new RetrievalViewSpec { Id = "motion_interference_dof", Name = "运动关系干涉与自由度视角", Focus = "运动副、自由度、相对运动、碰撞、干涉、行程和运动空间。" },
new RetrievalViewSpec { Id = "transmission_standard_parameters", Name = "传动关系与标准件参数视角", Focus = "轴承、齿轮、联轴器、键、螺栓、弹簧等标准件或传动件参数。" },
new RetrievalViewSpec { Id = "lubrication_sealing_flow", Name = "润滑密封排水排气与流动路径视角", Focus = "润滑路径、油孔、油槽、密封面、排水、排气、泄漏和流体通道。" },
new RetrievalViewSpec { Id = "operation_safety_environment", Name = "操作安全环境与维护视角", Focus = "操作空间、防护、人机、散热、腐蚀、噪声、清理和维护。" },
new RetrievalViewSpec { Id = "drawing_annotation_requirement", Name = "图纸标注尺寸公差与技术要求视角", Focus = "尺寸链、公差、形位公差、粗糙度、技术要求和图纸一致性。" }
],
GlobalMechanicalSemanticGraphSchema = new Dictionary<string, object?>
{
["parts"] = new[] { "part id, solidworks_instance_name, component_file_name, component_path, category, review_scope, inferred_role, material or blank candidate, confidence, evidence refs" },
["features"] = new[] { "feature id/type/owner part/face refs/geometry summary/possible functions/possible process roles" },
["relations"] = new[] { "contact/fit/coaxial/adjacent/flush/blocked/access/path/located_by/supports relations with evidence" },
["surface_roles"] = new[] { "machined candidate, unmachined candidate, contact surface, datum candidate, sealing surface, exterior/interior side" },
["component_function_profiles"] = new[] { "one profile per component: whole-component purpose only, solidworks instance, assembly role, function inputs/outputs, supported or supporting components, load/motion/sealing/locating responsibilities, related interface ids, confidence, evidence refs; do not place per-contact function conclusions here" },
["interface_function_profiles"] = new[] { "one profile per physical interface location: relation/contact ids, participating faces/components, B-rep contact type, locating/supporting/sealing/guiding/fastening/motion limiting/load transfer function, relative motion intent, axial/circumferential/radial constraint responsibilities, confidence, evidence refs; do not repeat the whole-component purpose" },
["connection_assessments"] = new[] { "one assessment per physical interface: intended fixed/sliding/rotating relation, torque/load transfer, disassembly intent, observed fastener/key/spline/weld/adhesive/clamp/shoulder/retaining-ring evidence, other positive-retention status, interference-fit necessity candidate, reasoning, confidence, alternatives and missing facts" },
["interface_drawing_requirements"] = new[] { "one requirement profile per physical interface: fit intent/category without inventing an exact ISO fit, tolerance-critical dimensions, geometric-tolerance intent, surface-finish intent for each participating face, drawing requirement confidence and evidence refs" },
["uncertainties"] = new[] { "facts that cannot be inferred reliably and suggested extraction/check" },
["component_evidence_packages"] = new[] { "one package per SolidWorks component instance; assembly-derived B-rep refs and matching image plan refs must not be mixed across components" },
["diagnosis_order"] = new[] { "assembly_level_first", "nonstandard_part_design_second" },
["evidence_refs"] = new[] { "B-rep feature ids, face refs, image names, component context image names" }
},
AssertionSchema = new Dictionary<string, object?>
{
["semantic_id"] = "stable id, such as manufacturing_semantic_1",
["semantic_type"] = "one of SemanticTypes.Name",
["target_feature_ids"] = new[] { "FeatureGraph feature ids" },
["statement"] = "short neutral mechanical semantic statement, not a final fault judgement",
["image_evidence"] = new[] { "view/image path plus what is seen" },
["image_evidence_names"] = new[] { "exact image names or plan ids used by this semantic assertion" },
["brep_evidence"] = new[] { "feature ids, face refs, measured radius/axis/area/contact facts" },
["confidence"] = "0.0-1.0",
["status"] = "confirmed | likely | candidate | uncertain | contradicted",
["missing_checks"] = new[] { "computed B-rep checks or visual checks still needed" }
},
LocalSemanticUnitSchema = new Dictionary<string, object?>
{
["local_id"] = "stable id, such as local_001",
["anchor_type"] = "part | feature | interface | functional_group",
["anchor_id"] = "component id, feature id, contact/mate relation id, or internal/component group id",
["primary_view"] = "one RetrievalViews.Id",
["secondary_views"] = new[] { "optional RetrievalViews.Id values" },
["retrieval_sentence"] = "one neutral fact sentence for retrieval; no rule id, no should/violate/fault wording",
["image_evidence_names"] = new[] { "exact component context, component highlight, or part image names that support this local unit" },
["image_observations"] = new[] { "image_name/observation/supports/confidence records" },
["fact_bundle"] = "BrepFactBundleSchema",
["source_semantic_ids"] = new[] { "global semantic assertion ids used to create this unit" },
["dedup_key"] = "primary_view + anchor id + core relation signature"
},
CoverageAuditSchema = new Dictionary<string, object?>
{
["covered_part_component_ids"] = new[] { "part_design_required component instance ids with at least one part-level local unit" },
["covered_feature_anchor_ids"] = new[] { "hole/hole_group/slot/boss/rib/step/shaft/sleeve/pad/thin_wall/machining/locating/contact face anchors covered by feature-level units" },
["covered_interface_relation_ids"] = new[] { "contact, fit, coaxial, mate, support, locating, motion, clearance, or accessibility relation ids covered by interface-level units" },
["covered_functional_group_ids"] = new[] { "internal component group or mechanism group ids covered by functional_group units" },
["uncovered_candidate_anchors"] = new[] { "anchor_type/anchor_id/reason_not_covered records for intentionally excluded candidates" },
["local_unit_count_by_anchor_type"] = new Dictionary<string, object?> { ["part"] = 0, ["feature"] = 0, ["interface"] = 0, ["functional_group"] = 0 }
},
BrepFactBundleSchema = new Dictionary<string, object?>
{
["anchor"] = new Dictionary<string, object?> { ["kind"] = "part | feature | face_group | relation | assembly_region", ["id"] = "feature/face/relation id", ["solidworks_name"] = "real component or feature owner name when available", ["component_file_name"] = "real component file name when available", ["inferred_role"] = "AI role label such as lower support seat or side bracket, never replacing the real name" },
["related_parts"] = new[] { "solidworks instance names, file names, paths, and inferred role candidates" },
["related_features"] = new[] { "feature ids and role candidates" },
["known_relations"] = new[] { "relation type, a, b, confidence, evidence refs" },
["assembly_function_context"] = new[] { "required for assembly part/feature units: component assembly role, inherited functional requirements, related interface_function_profile ids, supporting SolidWorks mate ids, B-rep contact ids, coaxial/flush/parallel relation facts, functional contact/mate face refs, related components, and confidence" },
["brep_facts"] = new[] { "type/source_ref/value/unit/confidence, e.g. radius_mm, area_mm2, axis, bbox_mm, adjacency, contact_candidate, distance_mm, angle_deg, thickness_mm, clearance_mm" },
["image_evidence_names"] = new[] { "exact image names or image plan ids supporting this fact bundle" },
["image_observations"] = new[] { "image_name/observation/supports/confidence records used for later image reload" },
["image_facts"] = new[] { "view/observation/confidence, e.g. internal side, blocked by wall, likely cover mounting region" },
["missing_facts"] = new[] { "fact/why_needed/suggested_extraction, used to trigger second-pass extraction" },
["confidence"] = "0.0-1.0",
["evidence_status"] = "confirmed | likely | candidate | uncertain | contradicted"
},
EvidenceStatusValues = ["confirmed", "likely", "candidate", "uncertain", "contradicted"],
ConfidencePolicy =
[
"Use confirmed only when image and B-rep evidence agree or the fact is directly measured.",
"Use likely when one evidence channel is strong and the other does not contradict it.",
"Use mechanical_common_sense_inference when B-rep, mates, contacts, and images do not directly state a function/process/property, but mechanical design knowledge can answer whether this kind of part or structure should normally have it.",
"Use candidate when the feature pattern is plausible but function, process intent, or local relation is missing.",
"Use uncertain when image and B-rep evidence cannot be aligned.",
"Use contradicted when a visual interpretation is not supported by corresponding B-rep feature facts."
],
DedupPolicy =
[
"Merge local units with the same primary_view, same anchor id, and same core relation signature.",
"If two units only differ by wording, keep the one with more concrete B-rep facts and merge evidence/missing_facts.",
"Do not create separate biased units such as risk/boundary/fault for the same anchor and view before retrieval; keep one neutral fact unit.",
"The same anchor may appear in multiple views only when the retrieval purpose differs, such as surface boundary vs tool access.",
"Never remove uncertainty or missing facts during deduplication."
],
RetrievalPolicy =
[
"Model-side semantic generation is knowledge-blind: no detailed rule store, no rule ids, no final fault words.",
"Use SolidWorks component names/file names as primary names. Put AI inferred functional names in parentheses or inferred_role fields only.",
"For assembly input, generate local units for assembly relations and for each nonstandard component with review_scope=part_design_required.",
"For assembly input, always infer component_function_profiles and interface_function_profiles before part-design local units. Part-level and feature-level local units must inherit the component's assembly role and functional face/interface roles in fact_bundle.assembly_function_context.",
"Keep whole-component purpose, physical-interface function, and connection-method assessment in separate records. They may reference each other by stable ids but must not copy conclusions across layers.",
"A B-rep contact proves geometric adjacency only. It does not prove clearance, transition, or interference fit and does not prove that the pair is positively retained.",
"For every cylindrical or load-transferring contact, inspect the whole assembly for alternative retention or torque-transfer mechanisms: screws/bolts, keys/splines, pins, shoulders, retaining rings, clamps, welds, adhesives and connected intermediate parts. Record observed, not_observed and unknown separately.",
"Consider interference fit only after deciding whether relative motion is prohibited, whether disassembly is intended, what loads must transfer, and whether another positive-retention mechanism exists. No observed mechanism is not proof of absence; keep missing evidence explicit.",
"Infer fit category and surface-finish intent from interface function and connection method, but do not invent an exact tolerance designation or Ra value without a retrieved standard/knowledge rule. Exact drawing values are judged downstream against the knowledge base.",
"When inferring whole-assembly function, explicitly use part-to-part mating relations: SolidWorks mates, B-rep contacts, coaxial cylindrical relations, planar contacts, support/locating faces, and relative positions. Do not infer a component function from its name or image alone when mating/contact evidence is available.",
"When diagnosing a component as a part inside an assembly, do not treat it as an isolated unknown part. Use its assembly_function_context to decide whether its faces/features are locating, supporting, sealing, guiding, fastening, bearing, load-carrying, motion-limiting, or access-related.",
"Do not run part-structure taboo checks on standard or purchased components with review_scope=assembly_context_only; use them only for mating context, occlusion/boundary context, purchased-component identification, or standard-part selection/calculation checks.",
"Motors, servos, reducers, gearboxes, and gear motors are purchased components by default. Identify them and preserve their assembly function, but do not create part-design local units for their internal structure.",
"Use ComponentContextImagePlan images as reusable internal component group evidence. These groups are geometry-derived, not AI functional group guesses.",
"Do not treat missing semantic labels as final missing information. If an attribute/function/process is not directly extractable from B-rep, contacts, mates, or images, perform mechanical_common_sense_inference before marking it unsupported.",
"Create local units by coverage anchors: every part_design_required component instance gets at least one part-level unit; supported local features get feature-level units; contacts/mates/fits/coaxial/support/locating/motion/accessibility relations get interface-level units; geometry-derived internal/component groups get functional_group units when group evidence matters.",
"Do not collapse separate physical locations into one generic local unit. Merge only repeated equivalent anchors with the same owner, role, dimensions, relation signature, primary_view, and evidence.",
"Write semantic_coverage_audit and list uncovered_candidate_anchors instead of silently omitting supported parts/features/interfaces/groups.",
"Use LocalSemanticUnit.retrieval_sentence as the main query and primary_view/secondary_views as filters or weights.",
"The retrieval index returns rule_id only; the detailed verification rule is loaded after retrieval.",
"Final judgement must cite fact_bundle items and verification_rule.required_model_facts.",
"If a likely candidate lacks required extractable facts, issue a second-pass extraction request rather than guessing."
]
};
}
static List<AssemblyFaceEvidence> FindHoleEntryFaceCandidates(AssemblyFaceEvidence hole, List<AssemblyFaceEvidence> planes)
{
if (hole.Axis.Length < 3 || hole.CenterMm.Length < 3)
return [];
var axis = Vec.Normalize(hole.Axis);
return planes
.Where(p => p.Normal.Length >= 3)
.Select(p => new
{
Face = p,
Parallel = Math.Abs(Vec.Dot(axis, Vec.Normalize(p.Normal))),
AxialDistance = Math.Abs(Vec.Dot(Vec.Sub(p.CenterMm, hole.CenterMm), axis)),
CenterDistance = Vec.Norm(Vec.Sub(p.CenterMm, hole.CenterMm))
})
.Where(x => x.Parallel >= 0.85)
.OrderBy(x => x.AxialDistance)
.ThenBy(x => x.CenterDistance)
.Take(4)
.Select(x => x.Face)
.ToList();
}
static string FaceRef(AssemblyFaceEvidence face) => $"{face.ComponentName}:face#{face.FaceIndex}";
static List<HoleFeatureGroup> BuildHoleFeatureGroups(List<AssemblyFaceEvidence> holeFaces, AssemblyFrameEvidence frame)
{
var result = new List<HoleFeatureGroup>();
var remaining = holeFaces
.Where(f => f.Axis.Length >= 3 && f.CenterMm.Length >= 3)
.OrderBy(f => f.FaceIndex)
.ToList();
foreach (var radiusGroup in remaining
.GroupBy(f => $"{f.ComponentName}:{Math.Round(f.RadiusMm, 1)}", StringComparer.OrdinalIgnoreCase)
.OrderBy(g => g.Min(f => f.RadiusMm)))
{
var candidates = radiusGroup.OrderBy(f => f.FaceIndex).ToList();
ExtractCircularHolePatterns(candidates, result, frame);
ExtractCoplanarParallelHolePatterns(candidates, result);
ExtractFallbackAxisRadiusGroups(candidates, result);
}
return result.OrderBy(g => g.Members.Min(f => f.FaceIndex)).ToList();
}
static void ExtractCircularHolePatterns(List<AssemblyFaceEvidence> candidates, List<HoleFeatureGroup> result, AssemblyFrameEvidence frame)
{
var patternAxes = CandidatePatternAxes(frame);
foreach (var patternAxis in patternAxes)
{
var free = candidates.Where(f => !IsAlreadyGrouped(f, result)).ToList();
if (free.Count < 3)
return;
var radius = free.Average(f => f.RadiusMm);
var planeTolerance = Math.Max(1.0, radius * 0.75);
foreach (var planeGroup in free
.GroupBy(f => $"{f.ComponentName}:{Quantize(Vec.Dot(f.CenterMm, patternAxis), planeTolerance)}", StringComparer.OrdinalIgnoreCase)
.Where(g => g.Count() >= 3)
.OrderByDescending(g => g.Count()))
{
var members = planeGroup.OrderBy(f => f.FaceIndex).ToList();
if (!TryDescribeCircularPattern(members, patternAxis, out var patternType, out var center, out var patternRadius, out var spacing))
continue;
result.Add(new HoleFeatureGroup
{
Members = members,
PatternType = patternType,
GroupingBasis = "same diameter, shared pattern plane, and center-parameter circular distribution",
PatternNormal = Vec.Round(patternAxis),
PatternPlaneTmm = members.Average(f => Vec.Dot(f.CenterMm, patternAxis)),
PatternCenterMm = center,
PatternRadiusMm = patternRadius,
SpacingMm = spacing
});
}
}
}
static void ExtractCoplanarParallelHolePatterns(List<AssemblyFaceEvidence> candidates, List<HoleFeatureGroup> result)
{
var free = candidates.Where(f => !IsAlreadyGrouped(f, result)).ToList();
if (free.Count < 2)
return;
var radius = free.Average(f => f.RadiusMm);
var planeTolerance = Math.Max(1.0, radius * 0.75);
foreach (var group in free
.GroupBy(f => $"{f.ComponentName}:{AxisKey(f.Axis)}:{Quantize(Vec.Dot(f.CenterMm, Vec.Normalize(f.Axis)), planeTolerance)}", StringComparer.OrdinalIgnoreCase)
.Where(g => g.Count() >= 2)
.OrderByDescending(g => g.Count()))
{
var members = group.OrderBy(f => f.FaceIndex).ToList();
var axis = Vec.Normalize(members[0].Axis);
var center = AveragePoint(members.Select(f => f.CenterMm));
result.Add(new HoleFeatureGroup
{
Members = members,
PatternType = ClassifyParallelCenterPattern(members, axis),
GroupingBasis = "same diameter, parallel axes, shared axial parameter plane, and center-parameter distribution",
PatternNormal = Vec.Round(axis),
PatternPlaneTmm = members.Average(f => Vec.Dot(f.CenterMm, axis)),
PatternCenterMm = center,
SpacingMm = EstimateAverageNearestSpacing(members)
});
}
}
static void ExtractFallbackAxisRadiusGroups(List<AssemblyFaceEvidence> candidates, List<HoleFeatureGroup> result)
{
foreach (var group in candidates
.Where(f => !IsAlreadyGrouped(f, result))
.GroupBy(f => $"{f.ComponentName}:{AxisKey(f.Axis)}:{Math.Round(f.RadiusMm, 1)}", StringComparer.OrdinalIgnoreCase)
.OrderBy(g => g.Min(f => f.RadiusMm)))
{
var members = group.OrderBy(f => f.FaceIndex).ToList();
result.Add(new HoleFeatureGroup
{
Members = members,
PatternType = members.Count > 1 ? "same_axis_same_diameter_hole_group" : "single_hole_candidate",
GroupingBasis = "fallback same component, axis direction, and diameter",
PatternNormal = members.Count > 0 ? Vec.Round(Vec.Normalize(members[0].Axis)) : [],
PatternCenterMm = AveragePoint(members.Select(f => f.CenterMm)),
SpacingMm = EstimateAverageNearestSpacing(members)
});
}
}
static List<double[]> CandidatePatternAxes(AssemblyFrameEvidence frame)
{
var axes = new List<double[]>();
AddAxis(frame.ShaftAxisMm);
AddAxis(frame.VerticalAxisMm);
AddAxis([1.0, 0.0, 0.0]);
AddAxis([0.0, 1.0, 0.0]);
AddAxis([0.0, 0.0, 1.0]);
return axes;
void AddAxis(double[]? axis)
{
if (axis == null || axis.Length < 3 || Vec.Norm(axis) < 1e-9)
return;
var normalized = CanonicalAxis(Vec.Normalize(axis));
if (!axes.Any(existing => Math.Abs(Vec.Dot(existing, normalized)) > 0.985))
axes.Add(normalized);
}
}
static bool TryDescribeCircularPattern(
List<AssemblyFaceEvidence> members,
double[] patternAxis,
out string patternType,
out double[] centerMm,
out double patternRadiusMm,
out double spacingMm)
{
patternType = "";
centerMm = [];
patternRadiusMm = 0;
spacingMm = 0;
if (members.Count < 3)
return false;
var axis = Vec.Normalize(patternAxis);
var (u, v) = PlaneBasis(axis);
var t = members.Average(f => Vec.Dot(f.CenterMm, axis));
var us = members.Select(f => Vec.Dot(f.CenterMm, u)).ToList();
var vs = members.Select(f => Vec.Dot(f.CenterMm, v)).ToList();
var centerU = us.Average();
var centerV = vs.Average();
centerMm = Vec.Add(Vec.Add(Vec.Mul(axis, t), Vec.Mul(u, centerU)), Vec.Mul(v, centerV));
var center = centerMm;
var distances = members
.Select(f => Vec.Norm(Vec.Sub(f.CenterMm, center)))
.ToList();
patternRadiusMm = distances.Average();
if (patternRadiusMm < Math.Max(2.0, members.Average(f => f.RadiusMm) * 1.5))
return false;
var patternRadius = patternRadiusMm;
var maxDistanceDeviation = distances.Max(d => Math.Abs(d - patternRadius));
if (maxDistanceDeviation > Math.Max(1.5, patternRadius * 0.12))
return false;
var parallelCount = members.Count(f => IsParallel(f.Axis, axis, 0.85));
var radialCount = members.Count(f =>
{
var radial = Vec.Sub(f.CenterMm, center);
radial = Vec.Sub(radial, Vec.Mul(axis, Vec.Dot(radial, axis)));
return Vec.Norm(radial) > 1e-6 && Math.Abs(Vec.Dot(Vec.Normalize(radial), Vec.Normalize(f.Axis))) >= 0.72;
});
if (parallelCount < members.Count - 1 && radialCount < members.Count - 1)
return false;
spacingMm = EstimateAverageNearestSpacing(members);
patternType = radialCount >= parallelCount
? "radial_circular_hole_pattern"
: "parallel_circular_hole_pattern";
return true;
}
static string ClassifyParallelCenterPattern(List<AssemblyFaceEvidence> members, double[] axis)
{
if (members.Count < 3)
return "coplanar_parallel_hole_pair";
var center = AveragePoint(members.Select(f => f.CenterMm));
var distances = members.Select(f => Vec.Norm(Vec.Sub(f.CenterMm, center))).ToList();
var average = distances.Average();
var maxDeviation = distances.Max(d => Math.Abs(d - average));
if (average > 1e-6 && maxDeviation <= Math.Max(1.5, average * 0.12))
return "parallel_circular_hole_pattern";
return "coplanar_parallel_hole_pattern";
}
static bool IsAlreadyGrouped(AssemblyFaceEvidence face, List<HoleFeatureGroup> groups) =>
groups.Any(g => g.Members.Any(m => ReferenceEquals(m, face)));
static int Quantize(double value, double tolerance) =>
(int)Math.Round(value / Math.Max(tolerance, 1e-6));
static double[] CanonicalAxis(double[] axis)
{
var n = Vec.Normalize(axis);
if (n[0] < -0.0001 || (Math.Abs(n[0]) < 0.0001 && n[1] < -0.0001) || (Math.Abs(n[0]) < 0.0001 && Math.Abs(n[1]) < 0.0001 && n[2] < -0.0001))
n = Vec.Mul(n, -1);
return n;
}
static double[] AveragePoint(IEnumerable<double[]> points)
{
var list = points.Where(p => p.Length >= 3).ToList();
if (list.Count == 0)
return [];
return
[
list.Average(p => p[0]),
list.Average(p => p[1]),
list.Average(p => p[2])
];
}
static double EstimateAverageNearestSpacing(List<AssemblyFaceEvidence> members)
{
if (members.Count < 2)
return 0;
var nearest = new List<double>();
foreach (var member in members)
{
var d = members
.Where(other => !ReferenceEquals(other, member))
.Select(other => Vec.Norm(Vec.Sub(other.CenterMm, member.CenterMm)))
.DefaultIfEmpty(0)
.Min();
if (d > 0)
nearest.Add(d);
}
return nearest.Count == 0 ? 0 : nearest.Average();
}
static string AxisKey(double[] axis)
{
if (axis.Length < 3 || Vec.Norm(axis) < 1e-9)
return "none";
var n = Vec.Normalize(axis);
if (n[0] < -0.0001 || (Math.Abs(n[0]) < 0.0001 && n[1] < -0.0001) || (Math.Abs(n[0]) < 0.0001 && Math.Abs(n[1]) < 0.0001 && n[2] < -0.0001))
n = Vec.Mul(n, -1);
return $"{Math.Round(n[0], 2)}_{Math.Round(n[1], 2)}_{Math.Round(n[2], 2)}";
}
#pragma warning disable CS8321 // Legacy section graph builders retained only for backward-compatible empty report fields.
static Canonical2DStructureGraph BuildCanonicalModel2D(SectionPlaneEvidence plane, List<SectionBodyEvidence> bodies)
{
var canonical = BuildCanonicalModelSection(plane, bodies);
var graph = new Canonical2DStructureGraph
{
Source = new Canonical2DSource
{
Kind = "model_section",
CoordinateSystem = "section_plane_uv_mm",
Metadata = canonical.Metadata
},
Edges = canonical.Edges.Select(e => new Canonical2DEdge
{
Id = e.Id,
Type = e.Kind,
Start = e.Start,
End = e.End,
Owner = Attr(e, "component"),
Role = "unknown",
SourceEntity = Attr(e, "provenance"),
Attributes = e.Attributes
}).ToList(),
Annotations = []
};
foreach (var group in graph.Edges.GroupBy(e => e.Owner).Where(g => !string.IsNullOrWhiteSpace(g.Key)))
{
graph.Regions.Add(new Canonical2DRegion
{
Id = $"region_{SanitizeId(group.Key)}",
Owner = group.Key,
Role = "material_region",
EdgeRefs = group.Select(e => e.Id).ToList()
});
}
graph.Regions.Add(new Canonical2DRegion
{
Id = "region_outside",
Owner = "environment",
Role = "outside_region",
EdgeRefs = []
});
foreach (var region in graph.Regions.Where(r => r.Role == "material_region"))
{
foreach (var edgeId in region.EdgeRefs)
{
graph.Relations.Add(new Canonical2DRelation
{
Type = "edge_bounds_region",
A = edgeId,
B = region.Id,
Attributes = new Dictionary<string, object?>
{
["region_role"] = region.Role,
["owner"] = region.Owner
}
});
}
}
graph.Relations.AddRange(BuildModel2DRelations(graph.Edges));
foreach (var edge in FindGlobalOutsideBoundaryEdges(graph.Edges))
{
edge.Role = "outer_boundary_candidate";
graph.Relations.Add(new Canonical2DRelation
{
Type = "adjacent_to_outside",
A = edge.Id,
B = "region_outside",
Attributes = new Dictionary<string, object?>
{
["reason"] = "edge_on_global_section_profile_extreme"
}
});
}
return graph;
}
static List<Canonical2DEdge> FindGlobalOutsideBoundaryEdges(List<Canonical2DEdge> edges)
{
var valid = edges.Where(e => e.Start.Length >= 2 && e.End.Length >= 2).ToList();
if (valid.Count == 0)
return [];
var xs = valid.SelectMany(e => new[] { e.Start[0], e.End[0] }).ToList();
var ys = valid.SelectMany(e => new[] { e.Start[1], e.End[1] }).ToList();
double xmin = xs.Min();
double xmax = xs.Max();
double ymin = ys.Min();
double ymax = ys.Max();
double tol = 0.35;
return valid
.Where(e => e.Type.Contains("line", StringComparison.OrdinalIgnoreCase))
.Where(e => Distance2D(e.Start, e.End) > 1.0)
.Where(e =>
(Math.Abs(e.Start[0] - xmin) <= tol && Math.Abs(e.End[0] - xmin) <= tol) ||
(Math.Abs(e.Start[0] - xmax) <= tol && Math.Abs(e.End[0] - xmax) <= tol) ||
(Math.Abs(e.Start[1] - ymin) <= tol && Math.Abs(e.End[1] - ymin) <= tol) ||
(Math.Abs(e.Start[1] - ymax) <= tol && Math.Abs(e.End[1] - ymax) <= tol))
.ToList();
}
static double Distance2D(double[] a, double[] b)
{
if (a.Length < 2 || b.Length < 2)
return 0;
double dx = a[0] - b[0];
double dy = a[1] - b[1];
return Math.Sqrt(dx * dx + dy * dy);
}
static List<Canonical2DRelation> BuildModel2DRelations(List<Canonical2DEdge> edges)
{
var result = new List<Canonical2DRelation>();
for (int i = 0; i < edges.Count; i++)
{
for (int j = i + 1; j < edges.Count; j++)
{
var a = edges[i];
var b = edges[j];
if (string.Equals(a.Owner, b.Owner, StringComparison.OrdinalIgnoreCase))
continue;
if (CoincidentLine(a, b, 0.25))
{
result.Add(new Canonical2DRelation
{
Type = "contact",
A = a.Id,
B = b.Id,
Attributes = new Dictionary<string, object?>
{
["relation"] = "coincident_section_edges",
["owner_a"] = a.Owner,
["owner_b"] = b.Owner
}
});
}
}
}
return result;
}
static bool CoincidentLine(Canonical2DEdge a, Canonical2DEdge b, double tolerance)
{
if (a.Start.Length < 2 || a.End.Length < 2 || b.Start.Length < 2 || b.End.Length < 2)
return false;
bool aHorizontal = Math.Abs(a.Start[1] - a.End[1]) <= tolerance;
bool bHorizontal = Math.Abs(b.Start[1] - b.End[1]) <= tolerance;
if (aHorizontal && bHorizontal && Math.Abs(a.Start[1] - b.Start[1]) <= tolerance)
return SegmentOverlap(a.Start[0], a.End[0], b.Start[0], b.End[0]) > tolerance;
bool aVertical = Math.Abs(a.Start[0] - a.End[0]) <= tolerance;
bool bVertical = Math.Abs(b.Start[0] - b.End[0]) <= tolerance;
if (aVertical && bVertical && Math.Abs(a.Start[0] - b.Start[0]) <= tolerance)
return SegmentOverlap(a.Start[1], a.End[1], b.Start[1], b.End[1]) > tolerance;
return false;
}
static double SegmentOverlap(double a0, double a1, double b0, double b1)
{
var amin = Math.Min(a0, a1);
var amax = Math.Max(a0, a1);
var bmin = Math.Min(b0, b1);
var bmax = Math.Max(b0, b1);
return Math.Max(0, Math.Min(amax, bmax) - Math.Max(amin, bmin));
}
static Canonical2DSketchGraph BuildSketchGraph(Canonical2DStructureGraph graph, string sourceKind, string units, double vertexTolerance)
{
var sketch = new Canonical2DSketchGraph
{
Source = new Canonical2DSketchSource
{
Kind = sourceKind,
CoordinateSystem = graph.Source.CoordinateSystem,
Units = units,
Metadata = graph.Source.Metadata
},
Relations = graph.Relations.Select(r => new SketchRelation
{
Type = r.Type,
A = r.A,
B = r.B,
Attributes = r.Attributes
}).ToList()
};
var unique = MergeDuplicateEdges(graph.Edges, vertexTolerance);
var vertexIndex = new Dictionary<string, SketchVertex>(StringComparer.OrdinalIgnoreCase);
foreach (var edge in unique)
{
var start = GetOrAddVertex(edge.Start);
var end = GetOrAddVertex(edge.End);
if (start.Id == end.Id)
continue;
double length = Math.Round(Distance2D(edge.Start, edge.End), 3);
var drawable = new SketchDrawableEdge
{
Id = $"sketch_edge_{sketch.Edges.Count + 1}",
SourceEdgeIds = edge.SourceIds,
Type = edge.Type,
StartVertex = start.Id,
EndVertex = end.Id,
Start = start.Point,
End = end.Point,
NormalizedStart = [],
NormalizedEnd = [],
Owner = edge.Owner,
Role = edge.Role,
Length = length,
Attributes = edge.Attributes
};
drawable.Attributes["orientation"] = EdgeOrientation(edge.Start, edge.End);
sketch.Edges.Add(drawable);
}
sketch.Vertices = vertexIndex.Values.OrderBy(v => v.Id, StringComparer.OrdinalIgnoreCase).ToList();
NormalizeSketchCoordinates(sketch);
sketch.Loops = BuildSketchLoops(sketch);
sketch.Paths = BuildSketchPaths(sketch);
foreach (var region in graph.Regions)
{
sketch.Regions.Add(new SketchRegion
{
Id = region.Id,
Owner = region.Owner,
Role = region.Role,
SourceEdgeRefs = region.EdgeRefs,
LoopRefs = sketch.Loops
.Where(loop => loop.SourceEdgeRefs.Any(id => region.EdgeRefs.Contains(id, StringComparer.OrdinalIgnoreCase)))
.Select(loop => loop.Id)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList()
});
}
sketch.Dimensions = BuildSketchDimensions(sketch, graph.Relations);
sketch.Dimensions.AddRange(BuildSketchGlobalDimensions(sketch));
return sketch;
SketchVertex GetOrAddVertex(double[] point)
{
var snapped = SnapPoint(point, vertexTolerance);
string key = $"{snapped[0]:0.###},{snapped[1]:0.###}";
if (vertexIndex.TryGetValue(key, out var existing))
return existing;
var vertex = new SketchVertex
{
Id = $"v{vertexIndex.Count + 1}",
Point = snapped,
NormalizedPoint = []
};
vertexIndex[key] = vertex;
return vertex;
}
}
#pragma warning restore CS8321
static List<SketchDimension> BuildSketchGlobalDimensions(Canonical2DSketchGraph sketch)
{
if (sketch.Vertices.Count == 0)
return [];
double minX = sketch.Vertices.Min(v => v.Point[0]);
double maxX = sketch.Vertices.Max(v => v.Point[0]);
double minY = sketch.Vertices.Min(v => v.Point[1]);
double maxY = sketch.Vertices.Max(v => v.Point[1]);
return
[
new SketchDimension
{
Id = "dim_global_width",
Kind = "global_width",
Value = Math.Round(maxX - minX, 3),
Units = sketch.Source.Units,
Role = "scale"
},
new SketchDimension
{
Id = "dim_global_height",
Kind = "global_height",
Value = Math.Round(maxY - minY, 3),
Units = sketch.Source.Units,
Role = "scale"
}
];
}
static List<MergedSketchEdge> MergeDuplicateEdges(List<Canonical2DEdge> edges, double tolerance)
{
var merged = new List<MergedSketchEdge>();
foreach (var edge in edges.Where(e => e.Start.Length >= 2 && e.End.Length >= 2))
{
var s = SnapPoint(edge.Start, tolerance);
var e = SnapPoint(edge.End, tolerance);
if (Distance2D(s, e) <= tolerance)
continue;
var keyA = PointKey(s);
var keyB = PointKey(e);
string key = string.CompareOrdinal(keyA, keyB) <= 0 ? $"{keyA}|{keyB}" : $"{keyB}|{keyA}";
var existing = merged.FirstOrDefault(item => item.Key == key && string.Equals(item.Owner, edge.Owner, StringComparison.OrdinalIgnoreCase));
if (existing != null)
{
existing.SourceIds.Add(edge.Id);
continue;
}
merged.Add(new MergedSketchEdge
{
Key = key,
Type = edge.Type,
Start = s,
End = e,
Owner = edge.Owner,
Role = edge.Role,
SourceIds = [edge.Id],
Attributes = new Dictionary<string, object?>(edge.Attributes)
});
}
return merged;
}
static double[] SnapPoint(double[] point, double tolerance)
{
if (point.Length < 2)
return [];
return
[
Math.Round(Math.Round(point[0] / tolerance) * tolerance, 3),
Math.Round(Math.Round(point[1] / tolerance) * tolerance, 3)
];
}
static string PointKey(double[] point) => point.Length < 2 ? "" : $"{point[0]:0.###},{point[1]:0.###}";
static string EdgeOrientation(double[] a, double[] b)
{
double dx = Math.Abs(a[0] - b[0]);
double dy = Math.Abs(a[1] - b[1]);
if (dx <= 0.35 && dy > 0.35)
return "vertical";
if (dy <= 0.35 && dx > 0.35)
return "horizontal";
return "oblique";
}
static void NormalizeSketchCoordinates(Canonical2DSketchGraph sketch)
{
if (sketch.Vertices.Count == 0)
return;
double minX = sketch.Vertices.Min(v => v.Point[0]);
double maxX = sketch.Vertices.Max(v => v.Point[0]);
double minY = sketch.Vertices.Min(v => v.Point[1]);
double maxY = sketch.Vertices.Max(v => v.Point[1]);
double span = Math.Max(Math.Max(maxX - minX, maxY - minY), 1e-6);
foreach (var vertex in sketch.Vertices)
{
vertex.NormalizedPoint =
[
Math.Round((vertex.Point[0] - minX) / span, 6),
Math.Round((vertex.Point[1] - minY) / span, 6)
];
}
var vertexById = sketch.Vertices.ToDictionary(v => v.Id, StringComparer.OrdinalIgnoreCase);
foreach (var edge in sketch.Edges)
{
edge.NormalizedStart = vertexById.TryGetValue(edge.StartVertex, out var sv) ? sv.NormalizedPoint : [];
edge.NormalizedEnd = vertexById.TryGetValue(edge.EndVertex, out var ev) ? ev.NormalizedPoint : [];
}
}
static List<SketchPath> BuildSketchPaths(Canonical2DSketchGraph sketch)
{
var paths = new List<SketchPath>();
var edgeById = sketch.Edges.ToDictionary(e => e.Id, StringComparer.OrdinalIgnoreCase);
var vertexById = sketch.Vertices.ToDictionary(v => v.Id, StringComparer.OrdinalIgnoreCase);
var adjacency = new Dictionary<string, List<SketchDrawableEdge>>(StringComparer.OrdinalIgnoreCase);
foreach (var edge in sketch.Edges)
{
Add(edge.StartVertex, edge);
Add(edge.EndVertex, edge);
}
var remaining = new HashSet<string>(sketch.Edges.Select(e => e.Id), StringComparer.OrdinalIgnoreCase);
while (remaining.Count > 0)
{
var component = CollectPathComponent(edgeById[remaining.First()], adjacency, remaining);
foreach (var edge in component)
remaining.Remove(edge.Id);
var ordered = OrderPathComponent(component, adjacency);
var vertexRefs = OrderedPathVertices(ordered);
bool closed = vertexRefs.Count > 2 && string.Equals(vertexRefs[0], vertexRefs[^1], StringComparison.OrdinalIgnoreCase);
if (closed)
vertexRefs.RemoveAt(vertexRefs.Count - 1);
paths.Add(new SketchPath
{
Id = $"path_{paths.Count + 1}",
EdgeRefs = ordered.Select(e => e.Id).ToList(),
SourceEdgeRefs = ordered.SelectMany(e => e.SourceEdgeIds).Distinct(StringComparer.OrdinalIgnoreCase).ToList(),
VertexRefs = vertexRefs,
Polyline = vertexRefs
.Where(vertexById.ContainsKey)
.Select(v => vertexById[v].NormalizedPoint.Length >= 2 ? vertexById[v].NormalizedPoint : vertexById[v].Point)
.ToList(),
Closed = closed,
Role = closed ? "closed_drawable_contour" : "open_drawable_contour"
});
}
return paths
.OrderByDescending(p => p.EdgeRefs.Count)
.ThenBy(p => p.Id, StringComparer.OrdinalIgnoreCase)
.ToList();
void Add(string vertex, SketchDrawableEdge edge)
{
if (!adjacency.TryGetValue(vertex, out var list))
adjacency[vertex] = list = [];
list.Add(edge);
}
}
static List<SketchDrawableEdge> CollectPathComponent(SketchDrawableEdge seed, Dictionary<string, List<SketchDrawableEdge>> adjacency, HashSet<string> available)
{
var result = new List<SketchDrawableEdge>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { seed.Id };
var queue = new Queue<SketchDrawableEdge>();
queue.Enqueue(seed);
while (queue.Count > 0)
{
var edge = queue.Dequeue();
result.Add(edge);
foreach (var vertex in new[] { edge.StartVertex, edge.EndVertex })
{
if (!adjacency.TryGetValue(vertex, out var incident))
continue;
foreach (var next in incident)
{
if (!available.Contains(next.Id) || !seen.Add(next.Id))
continue;
queue.Enqueue(next);
}
}
}
return result;
}
static List<SketchDrawableEdge> OrderPathComponent(List<SketchDrawableEdge> component, Dictionary<string, List<SketchDrawableEdge>> adjacency)
{
var edgeSet = component.Select(e => e.Id).ToHashSet(StringComparer.OrdinalIgnoreCase);
string start = component
.SelectMany(e => new[] { e.StartVertex, e.EndVertex })
.GroupBy(v => v, StringComparer.OrdinalIgnoreCase)
.Where(g => g.Count() == 1)
.Select(g => g.Key)
.OrderBy(v => v, StringComparer.OrdinalIgnoreCase)
.FirstOrDefault() ?? component[0].StartVertex;
var ordered = new List<SketchDrawableEdge>();
var used = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
string current = start;
while (used.Count < component.Count)
{
var next = adjacency.TryGetValue(current, out var incident)
? incident.FirstOrDefault(e => edgeSet.Contains(e.Id) && !used.Contains(e.Id))
: null;
next ??= component.FirstOrDefault(e => !used.Contains(e.Id));
if (next == null)
break;
ordered.Add(next);
used.Add(next.Id);
current = next.StartVertex == current ? next.EndVertex : next.StartVertex;
}
return ordered;
}
static List<string> OrderedPathVertices(List<SketchDrawableEdge> ordered)
{
if (ordered.Count == 0)
return [];
var vertices = new List<string> { ordered[0].StartVertex, ordered[0].EndVertex };
string current = ordered[0].EndVertex;
for (int i = 1; i < ordered.Count; i++)
{
var edge = ordered[i];
if (edge.StartVertex == current)
current = edge.EndVertex;
else if (edge.EndVertex == current)
current = edge.StartVertex;
else
{
vertices.Add(edge.StartVertex);
current = edge.EndVertex;
}
vertices.Add(current);
}
return vertices;
}
static List<SketchLoop> BuildSketchLoops(Canonical2DSketchGraph sketch)
{
var loops = new List<SketchLoop>();
var edgeById = sketch.Edges.ToDictionary(e => e.Id, StringComparer.OrdinalIgnoreCase);
var adjacency = new Dictionary<string, List<SketchDrawableEdge>>(StringComparer.OrdinalIgnoreCase);
foreach (var edge in sketch.Edges)
{
Add(edge.StartVertex, edge);
Add(edge.EndVertex, edge);
}
var visited = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var edge in sketch.Edges)
{
if (visited.Contains(edge.Id))
continue;
var loopEdges = new List<string>();
string startVertex = edge.StartVertex;
string currentVertex = edge.EndVertex;
string currentEdgeId = edge.Id;
loopEdges.Add(edge.Id);
visited.Add(edge.Id);
for (int guard = 0; guard < sketch.Edges.Count + 2; guard++)
{
var next = adjacency.TryGetValue(currentVertex, out var candidates)
? candidates.FirstOrDefault(e => !visited.Contains(e.Id))
: null;
if (next == null)
break;
visited.Add(next.Id);
loopEdges.Add(next.Id);
currentVertex = next.StartVertex == currentVertex ? next.EndVertex : next.StartVertex;
currentEdgeId = next.Id;
if (currentVertex == startVertex)
break;
}
bool closed = currentVertex == startVertex && loopEdges.Count >= 3;
if (closed)
{
var sourceRefs = loopEdges
.SelectMany(id => edgeById.TryGetValue(id, out var e) ? e.SourceEdgeIds : [])
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
loops.Add(new SketchLoop
{
Id = $"loop_{loops.Count + 1}",
EdgeRefs = loopEdges,
SourceEdgeRefs = sourceRefs,
Closed = true,
Role = "profile_loop"
});
}
}
return loops;
void Add(string vertex, SketchDrawableEdge edge)
{
if (!adjacency.TryGetValue(vertex, out var list))
adjacency[vertex] = list = [];
list.Add(edge);
}
}
static List<SketchDimension> BuildSketchDimensions(Canonical2DSketchGraph sketch, List<Canonical2DRelation> relations)
{
var dims = new List<SketchDimension>();
foreach (var edge in sketch.Edges)
{
dims.Add(new SketchDimension
{
Id = $"dim_length_{edge.Id}",
Kind = "edge_length",
TargetRefs = [edge.Id],
Value = edge.Length,
Units = sketch.Source.Units,
Role = edge.Role
});
}
foreach (var contact in relations.Where(r => r.Type == "contact"))
{
dims.Add(new SketchDimension
{
Id = $"dim_contact_{dims.Count + 1}",
Kind = "contact_length_candidate",
TargetRefs = [contact.A, contact.B],
Value = EstimateContactLength(sketch, contact.A, contact.B),
Units = sketch.Source.Units,
Role = "close_fit_contact"
});
}
foreach (var edge in sketch.Edges.Where(e => string.Equals(e.Role, "outer_boundary_candidate", StringComparison.OrdinalIgnoreCase)))
{
dims.Add(new SketchDimension
{
Id = $"dim_outer_{edge.Id}",
Kind = "outer_span",
TargetRefs = [edge.Id],
Value = Math.Round(edge.Length, 3),
Units = sketch.Source.Units,
Role = edge.Role
});
}
foreach (var edge in sketch.Edges.Where(e => string.Equals(e.Role, "push_face", StringComparison.OrdinalIgnoreCase)))
{
dims.Add(new SketchDimension
{
Id = $"dim_push_{edge.Id}",
Kind = "push_face_height_candidate",
TargetRefs = [edge.Id],
Value = Math.Round(edge.Length, 3),
Units = sketch.Source.Units,
Role = edge.Role
});
}
foreach (var edge in sketch.Edges.Where(e => IsSleeveOwner(e.Owner) && IsOutsideAdjacent(sketch, e) && IsVerticalEdge(e)))
{
dims.Add(new SketchDimension
{
Id = $"dim_exposed_sleeve_end_{edge.Id}",
Kind = "exposed_sleeve_end_height_candidate",
TargetRefs = [edge.Id],
Value = Math.Round(edge.Length, 3),
Units = sketch.Source.Units,
Role = "exposed_push_face_candidate"
});
}
return dims;
}
static bool IsSleeveOwner(string owner)
{
return owner.Contains("\u5957\u7b52", StringComparison.OrdinalIgnoreCase) ||
owner.Contains("\u8f74\u5957", StringComparison.OrdinalIgnoreCase) ||
owner.Contains("\u886c\u5957", StringComparison.OrdinalIgnoreCase) ||
owner.Contains("sleeve", StringComparison.OrdinalIgnoreCase) ||
owner.Contains("bushing", StringComparison.OrdinalIgnoreCase);
}
static bool IsOutsideAdjacent(Canonical2DSketchGraph sketch, SketchDrawableEdge edge)
{
return sketch.Relations.Any(r =>
r.Type == "adjacent_to_outside" &&
(string.Equals(r.A, edge.Id, StringComparison.OrdinalIgnoreCase) ||
edge.SourceEdgeIds.Any(id => string.Equals(r.A, id, StringComparison.OrdinalIgnoreCase))));
}
static bool IsVerticalEdge(SketchDrawableEdge edge)
{
if (edge.Start.Length < 2 || edge.End.Length < 2)
return false;
return Math.Abs(edge.Start[0] - edge.End[0]) <= 0.35 && Math.Abs(edge.Start[1] - edge.End[1]) > 1.0;
}
static double EstimateContactLength(Canonical2DSketchGraph sketch, string aId, string bId)
{
if (!TryEdge(sketch, aId, out var a) || !TryEdge(sketch, bId, out var b))
return 0;
if (a.StartVertex == b.StartVertex || a.StartVertex == b.EndVertex || a.EndVertex == b.StartVertex || a.EndVertex == b.EndVertex)
return Math.Round(Math.Min(a.Length, b.Length), 3);
return Math.Round((a.Length + b.Length) * 0.5, 3);
}
static bool TryEdge(Canonical2DSketchGraph sketch, string id, out SketchDrawableEdge edge)
{
edge = sketch.Edges.FirstOrDefault(e =>
string.Equals(e.Id, id, StringComparison.OrdinalIgnoreCase) ||
e.SourceEdgeIds.Any(sourceId => string.Equals(sourceId, id, StringComparison.OrdinalIgnoreCase))) ?? new SketchDrawableEdge();
return !string.IsNullOrWhiteSpace(edge.Id);
}
static string Attr(CanonicalSectionEdge edge, string key)
{
if (!edge.Attributes.TryGetValue(key, out var value) || value == null)
return "";
return value.ToString() ?? "";
}
static string SanitizeId(string value)
{
return string.Concat(value.Select(ch => char.IsLetterOrDigit(ch) ? ch : '_'));
}
static CanonicalSectionBrep BuildCanonicalModelSection(SectionPlaneEvidence plane, List<SectionBodyEvidence> bodies)
{
var edges = new List<CanonicalSectionEdge>();
foreach (var body in bodies)
{
foreach (var entity in body.SectionEntities.Where(e => e.EntityKind == "edge"))
{
var start3 = GetDoubleArray(entity.Geometry, "curve_start");
var end3 = GetDoubleArray(entity.Geometry, "curve_end");
if (start3.Length < 3 || end3.Length < 3)
continue;
var start2 = ProjectToSection2D(start3, plane);
var end2 = ProjectToSection2D(end3, plane);
edges.Add(new CanonicalSectionEdge
{
Id = $"{body.ComponentName}:edge_{entity.EntityIndex}",
Kind = EdgeKind(entity.Geometry),
Start = start2,
End = end2,
Length = Math.Round(Vec.Norm(Vec.Sub(end2, start2)), 3),
Attributes = new Dictionary<string, object?>
{
["component"] = body.ComponentName,
["component_category"] = body.Category,
["provenance"] = entity.Provenance,
["source"] = "solidworks_section_intersection",
["is_line"] = entity.Geometry.GetValueOrDefault("is_line"),
["is_circle"] = entity.Geometry.GetValueOrDefault("is_circle"),
["is_ellipse"] = entity.Geometry.GetValueOrDefault("is_ellipse")
}
});
}
}
return new CanonicalSectionBrep
{
SourceKind = "solidworks_model_section",
CoordinateSystem = "section_plane_uv_mm",
Metadata = new Dictionary<string, object?>
{
["origin_mm"] = plane.OriginMm,
["axis_mm"] = plane.AxisMm,
["normal_mm"] = plane.PlaneNormalMm,
["u_axis_mm"] = plane.UAxisMm,
["v_axis_mm"] = plane.VAxisMm
},
Edges = edges,
Annotations = []
};
}
static double[] ProjectToSection2D(double[] pointMm, SectionPlaneEvidence plane)
{
var rel = Vec.Sub(pointMm, plane.OriginMm);
return [Math.Round(Vec.Dot(rel, plane.UAxisMm), 3), Math.Round(Vec.Dot(rel, plane.VAxisMm), 3)];
}
static string EdgeKind(Dictionary<string, object?> geometry)
{
if (geometry.TryGetValue("is_line", out var line) && line is bool isLine && isLine)
return "line";
if (geometry.TryGetValue("is_circle", out var circle) && circle is bool isCircle && isCircle)
return "arc_or_circle";
if (geometry.TryGetValue("is_ellipse", out var ellipse) && ellipse is bool isEllipse && isEllipse)
return "ellipse";
return "curve";
}
static double[] GetDoubleArray(Dictionary<string, object?> data, string key)
{
if (!data.TryGetValue(key, out var value) || value == null)
return [];
if (value is double[] d)
return d;
if (value is JsonElement json && json.ValueKind == JsonValueKind.Array)
return json.EnumerateArray().Select(e => e.GetDouble()).ToArray();
if (value is object[] o)
return o.Select(Convert.ToDouble).ToArray();
return [];
}
static List<ComponentEvidence> EnumerateAssemblyComponents(
AssemblyDoc assy,
ModelDoc2 doc,
dynamic mathUtility,
IReadOnlyList<string> purchasedComponentHints,
IReadOnlyList<string> externalInterfaceRoots,
Action<string, string> log)
{
var result = new List<ComponentEvidence>();
var rawComponents = assy.GetComponents(false) as object[] ?? [];
var componentObjects = rawComponents.OfType<Component2>().ToList();
var purchasedVisibilityRoots = BuildPurchasedVisibilityRootNames(componentObjects, purchasedComponentHints);
var visibleFaceKeyCache = new Dictionary<string, HashSet<IntPtr>>(StringComparer.OrdinalIgnoreCase);
var transformSnapshotCache = new Dictionary<string, double[]>(StringComparer.OrdinalIgnoreCase);
var total = componentObjects.Count;
var index = 0;
foreach (var comp in componentObjects)
{
index++;
var name = Safe(() => comp.Name2, "");
var path = Safe(() => comp.GetPathName(), "");
if (IsPurchasedVisibilityChild(name, purchasedVisibilityRoots))
{
log("enumerate_component_skipped_purchased_child", $"{index}/{total} {ComponentDisplayName(name, path)} parent={ResolvePurchasedVisibilityGroupName(name, purchasedVisibilityRoots)}");
continue;
}
var category = IsUserPurchasedComponent(name, path, purchasedComponentHints)
? "user_purchased_component"
: ClassifyStable(name, path);
var reviewScope = IsStandardOrPurchasedComponent(category, name, path)
? "assembly_context_only"
: "part_design_required";
var skipDetailedBrep = ShouldSkipDetailedAssemblyBrep(category, name, path);
var preserveExternalInterfaceFaces = IsUserPurchasedComponent(name, path, externalInterfaceRoots);
log("enumerate_component", $"{index}/{total} {ComponentDisplayName(name, path)} category={category} review_scope={reviewScope}");
var component = new ComponentEvidence { Name = name, Path = path, Category = category, Transform = Safe(() => comp.Transform2 as MathTransform, null) };
if (skipDetailedBrep)
{
var visibilityGroupName = ResolvePurchasedVisibilityGroupName(name, purchasedVisibilityRoots);
var visibilityGroupComponents = ComponentsInVisibilityGroup(componentObjects, visibilityGroupName);
if (visibilityGroupComponents.Count == 0)
visibilityGroupComponents = [comp];
foreach (var body in visibilityGroupComponents.SelectMany(EnumerateComponentBodies))
component.Bodies.Add(body);
var indexedFaces = EnumerateComponentGroupIndexedFaces(visibilityGroupComponents);
component.BoundingBox = ComponentGroupBox(visibilityGroupComponents) ?? ComponentBoxFromComponent(comp);
if (preserveExternalInterfaceFaces)
{
foreach (var face in indexedFaces)
{
var owner = face.OwnerComponent ?? comp;
var evidence = BuildFaceEvidence(face.Face, face.FaceIndex, owner, GetComponentTransformSnapshot(owner, transformSnapshotCache));
if (evidence != null)
component.Faces.Add(evidence);
}
log(
"enumerate_component_external_interface_black_box",
$"{index}/{total} {ComponentDisplayName(name, path)} internal_pairs=excluded full_leaf_faces={component.Faces.Count} leaf_components={visibilityGroupComponents.Count}");
}
else if (component.BoundingBox.HasValue)
{
var visibleFaces = SelectMultiViewVisibleFaces(
doc,
assy,
comp,
component.BoundingBox.Value,
indexedFaces,
visibilityGroupComponents,
visibilityGroupName,
visibleFaceKeyCache,
log);
foreach (var face in visibleFaces)
{
var owner = face.OwnerComponent ?? comp;
var evidence = BuildFaceEvidence(face.Face, face.FaceIndex, owner, GetComponentTransformSnapshot(owner, transformSnapshotCache));
if (evidence != null)
component.Faces.Add(evidence);
}
}
if (component.Faces.Count == 0)
{
foreach (var face in SelectOuterEnvelopeIndexedFaces(indexedFaces, component.BoundingBox, mathUtility))
{
var owner = face.OwnerComponent ?? comp;
var evidence = BuildFaceEvidence(face.Face, face.FaceIndex, owner, GetComponentTransformSnapshot(owner, transformSnapshotCache));
if (evidence != null)
component.Faces.Add(evidence);
}
}
component.Cylinders.AddRange(component.Faces.Where(f => f.Kind == "cylinder"));
log("enumerate_component_visible_faces_purchased", $"{index}/{total} {ComponentDisplayName(name, path)} scope=overall_envelope_only bbox={(component.BoundingBox.HasValue ? "yes" : "no")} bodies={component.Bodies.Count} envelope_faces={component.Faces.Count} candidate_face_refs={indexedFaces.Count}");
result.Add(component);
continue;
}
foreach (var body in EnumerateComponentBodies(comp))
component.Bodies.Add(body);
var componentTransformSnapshot = GetComponentTransformSnapshot(comp, transformSnapshotCache);
foreach (var body in component.Bodies)
component.Faces.AddRange(EnumerateFaces(body, comp, componentTransformSnapshot));
component.Cylinders.AddRange(component.Faces.Where(f => f.Kind == "cylinder"));
component.BoundingBox = ComponentBox(component) ?? ComponentBoxFromComponent(comp);
log("enumerate_component_completed", $"{index}/{total} {ComponentDisplayName(name, path)} bodies={component.Bodies.Count} faces={component.Faces.Count}");
result.Add(component);
}
return result;
}
static List<ComponentEvidence> EnumeratePartComponents(ModelDoc2 doc, dynamic mathUtility, IReadOnlyList<string> purchasedComponentHints)
{
var result = new List<ComponentEvidence>();
if (doc is not PartDoc part)
return result;
var name = Safe(() => doc.GetTitle(), "");
var path = Safe(() => doc.GetPathName(), "");
var category = IsUserPurchasedComponent(name, path, purchasedComponentHints)
? "user_purchased_component"
: ClassifyStable(name, path);
var component = new ComponentEvidence { Name = name, Path = path, Category = category };
foreach (object bodyObj in ToObjectArray(part.GetBodies2((int)swBodyType_e.swSolidBody, true)))
{
if (bodyObj is Body2 body)
{
component.Bodies.Add(body);
component.Faces.AddRange(EnumerateFaces(body, null, []));
}
}
component.Cylinders.AddRange(component.Faces.Where(f => f.Kind == "cylinder"));
component.BoundingBox = ComponentBox(component);
result.Add(component);
return result;
}
static List<string> BuildPurchasedVisibilityRootNames(IReadOnlyList<Component2> components, IReadOnlyList<string> purchasedComponentHints)
{
return components
.Select(component =>
{
var name = Safe(() => component.Name2, "");
var path = Safe(() => component.GetPathName(), "");
var category = IsUserPurchasedComponent(name, path, purchasedComponentHints)
? "user_purchased_component"
: ClassifyStable(name, path);
return (Name: name, Path: path, Category: category);
})
.Where(item => IsPurchasedVisibilityRoot(item.Category, item.Name, item.Path))
.Select(item => item.Name)
.Where(name => !string.IsNullOrWhiteSpace(name))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderByDescending(name => name.Length)
.ToList();
}
static bool IsPurchasedVisibilityRoot(string category, string name, string path)
{
if (category.Equals("user_purchased_component", StringComparison.OrdinalIgnoreCase))
return false;
if (!IsStandardOrPurchasedComponent(category, name, path))
return false;
var extension = Path.GetExtension(path);
if (extension.Equals(".SLDASM", StringComparison.OrdinalIgnoreCase))
return true;
var text = $"{name} {Path.GetFileNameWithoutExtension(path)}";
return ContainsAny(text, "小臂关节装配2", "灏忚噦鍏宠妭瑁呴厤2");
}
static string ResolvePurchasedVisibilityGroupName(string componentName, IReadOnlyList<string> purchasedVisibilityRoots)
{
return purchasedVisibilityRoots.FirstOrDefault(root => IsSameOrChildComponentName(componentName, root)) ?? componentName;
}
static bool IsPurchasedVisibilityChild(string componentName, IReadOnlyList<string> purchasedVisibilityRoots)
{
return purchasedVisibilityRoots.Any(root =>
!componentName.Equals(root, StringComparison.OrdinalIgnoreCase) &&
IsSameOrChildComponentName(componentName, root));
}
static List<Component2> ComponentsInVisibilityGroup(IReadOnlyList<Component2> components, string visibilityGroupName)
{
return components
.Where(component => IsSameOrChildComponentName(Safe(() => component.Name2, ""), visibilityGroupName))
.ToList();
}
static Box? ComponentGroupBox(IReadOnlyList<Component2> components)
{
var boxes = components
.Select(ComponentBoxFromComponent)
.Where(box => box.HasValue)
.Select(box => box!.Value)
.ToList();
if (boxes.Count == 0)
return null;
return new Box(
boxes.Min(box => box.XMin),
boxes.Min(box => box.YMin),
boxes.Min(box => box.ZMin),
boxes.Max(box => box.XMax),
boxes.Max(box => box.YMax),
boxes.Max(box => box.ZMax));
}
static List<Body2> EnumerateComponentBodies(Component2 component)
{
try
{
dynamic dyn = component;
object info;
return ToObjectArray(dyn.GetBodies3((int)swBodyType_e.swSolidBody, out info)).OfType<Body2>().ToList();
}
catch
{
return [];
}
}
static List<FaceEvidence> EnumerateFaces(Body2 body, Component2? component, double[] transformSnapshot)
{
var result = new List<FaceEvidence>();
foreach (var item in EnumerateIndexedFaces([body], component))
{
var evidence = BuildFaceEvidence(item.Face, item.FaceIndex, component, transformSnapshot);
if (evidence != null)
result.Add(evidence);
}
return result;
}
static List<IndexedFace> EnumerateComponentGroupIndexedFaces(IReadOnlyList<Component2> components)
{
var result = new List<IndexedFace>();
var faceIndex = 0;
foreach (var component in components)
{
foreach (var body in EnumerateComponentBodies(component))
{
for (object? faceObj = Safe<object?>(() => body.GetFirstFace(), null); faceObj != null;)
{
faceIndex++;
if (faceObj is Face2 face)
{
result.Add(new IndexedFace(faceIndex, face, component));
faceObj = Safe<object?>(() => face.GetNextFace(), null);
}
else
{
break;
}
}
}
}
return result;
}
static List<IndexedFace> EnumerateIndexedFaces(IEnumerable<Body2> bodies, Component2? ownerComponent)
{
var result = new List<IndexedFace>();
int faceIndex = 0;
foreach (var body in bodies)
{
faceIndex = 0;
for (object? faceObj = Safe<object?>(() => body.GetFirstFace(), null); faceObj != null;)
{
faceIndex++;
if (faceObj is Face2 face)
{
result.Add(new IndexedFace(faceIndex, face, ownerComponent));
faceObj = Safe<object?>(() => face.GetNextFace(), null);
}
else
{
break;
}
}
}
return result;
}
static FaceEvidence? BuildFaceEvidence(Face2 face, int faceIndex, Component2? component, double[] transformSnapshot)
{
var surface = Safe(() => face.GetSurface() as Surface, null);
if (surface == null)
return null;
var box = ToDoubleArray(Safe<object?>(() => face.GetBox(), null)).Take(6).Select(v => v * 1000.0).ToArray();
if (box.Length < 6)
return null;
double[] centerLocalMm = [(box[0] + box[3]) / 2.0, (box[1] + box[4]) / 2.0, (box[2] + box[5]) / 2.0];
var centerMm = component == null ? centerLocalMm : TransformPointBySnapshot(transformSnapshot, centerLocalMm);
var evidence = new FaceEvidence
{
FaceIndex = faceIndex,
Box = Box.FromArray(component == null ? box : TransformBBoxBySnapshot(transformSnapshot, box)),
AreaMm2 = Safe(() => face.GetArea() * 1_000_000.0, 0),
CenterMm = centerMm,
RuntimeFace = face,
AssemblyTransformSnapshot = transformSnapshot
};
if (Safe(() => surface.IsCylinder(), false))
{
var c = ToDoubleArray(GetComProperty(surface, "CylinderParams"));
if (c.Length >= 7)
{
evidence.Kind = "cylinder";
double[] axisPoint = [c[0] * 1000.0, c[1] * 1000.0, c[2] * 1000.0];
double[] axis = [c[3], c[4], c[5]];
evidence.AxisPointMm = component == null ? axisPoint : TransformPointBySnapshot(transformSnapshot, axisPoint);
evidence.Axis = Vec.Normalize(component == null ? axis : TransformVectorBySnapshot(transformSnapshot, axis));
evidence.RadiusMm = c[6] * 1000.0;
}
}
else if (Safe(() => surface.IsPlane(), false))
{
evidence.Kind = "plane";
var p = ToDoubleArray(GetComProperty(surface, "PlaneParams"));
if (p.Length >= 6)
{
var normal = component == null ? [p[0], p[1], p[2]] : TransformVectorBySnapshot(transformSnapshot, [p[0], p[1], p[2]]);
evidence.Normal = Vec.Normalize(normal);
evidence.RootPointMm = component == null
? [p[3] * 1000.0, p[4] * 1000.0, p[5] * 1000.0]
: TransformPointBySnapshot(transformSnapshot, [p[3] * 1000.0, p[4] * 1000.0, p[5] * 1000.0]);
}
}
return evidence;
}
static FaceTessellation ExtractFaceTessellation(
Face2 face,
double[] transformSnapshot,
int maxTriangles = 512,
int maxSamples = 320,
Box? expectedAssemblyBox = null)
{
var raw = ToDoubleArray(Safe<object?>(() =>
{
dynamic f = face;
return f.GetTessTriangles(false);
}, null));
if (raw.Length < 9)
return new FaceTessellation([], []);
var rawTriangles = new List<FaceTriangleMm>();
for (int i = 0; i + 8 < raw.Length; i += 9)
{
var a = TessellationPointMm(raw, i);
var b = TessellationPointMm(raw, i + 3);
var c = TessellationPointMm(raw, i + 6);
if (TriangleAreaMm2(a, b, c) > 1e-8)
rawTriangles.Add(new FaceTriangleMm(a, b, c));
}
var allTriangles = SelectBestAssemblyTessellation(rawTriangles, transformSnapshot, expectedAssemblyBox);
var triangles = maxTriangles <= 0 || allTriangles.Count <= maxTriangles
? allTriangles
: Enumerable.Range(0, maxTriangles)
.Select(i => allTriangles[Math.Min(allTriangles.Count - 1, (int)Math.Floor(i * allTriangles.Count / (double)maxTriangles))])
.ToList();
if (maxSamples <= 0)
return new FaceTessellation([], triangles);
var samples = new List<double[]>();
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (var triangle in triangles)
{
AddSample(triangle.A);
AddSample(triangle.B);
AddSample(triangle.C);
AddSample(Vec.Mul(Vec.Add(Vec.Add(triangle.A, triangle.B), triangle.C), 1.0 / 3.0));
}
if (samples.Count > maxSamples)
{
var allSamples = samples;
samples = Enumerable.Range(0, maxSamples)
.Select(i => allSamples[Math.Min(allSamples.Count - 1, (int)Math.Floor(i * allSamples.Count / (double)maxSamples))])
.ToList();
}
return new FaceTessellation(samples, triangles);
void AddSample(double[] point)
{
var key = $"{Math.Round(point[0], 3)}:{Math.Round(point[1], 3)}:{Math.Round(point[2], 3)}";
if (seen.Add(key))
samples.Add(point);
}
}
static double[] TessellationPointMm(double[] raw, int index)
{
return [raw[index] * 1000.0, raw[index + 1] * 1000.0, raw[index + 2] * 1000.0];
}
static List<FaceTriangleMm> SelectBestAssemblyTessellation(
List<FaceTriangleMm> rawTriangles,
double[] transformSnapshot,
Box? expectedAssemblyBox)
{
if (rawTriangles.Count == 0 || transformSnapshot.Length < 13)
return rawTriangles;
var transformedTriangles = rawTriangles
.Select(triangle => new FaceTriangleMm(
TransformPointBySnapshot(transformSnapshot, triangle.A),
TransformPointBySnapshot(transformSnapshot, triangle.B),
TransformPointBySnapshot(transformSnapshot, triangle.C)))
.ToList();
if (!expectedAssemblyBox.HasValue)
return transformedTriangles;
var rawScore = TessellationBoxScore(rawTriangles, expectedAssemblyBox.Value);
var transformedScore = TessellationBoxScore(transformedTriangles, expectedAssemblyBox.Value);
return rawScore <= transformedScore ? rawTriangles : transformedTriangles;
}
static double TessellationBoxScore(IReadOnlyList<FaceTriangleMm> triangles, Box expected)
{
if (triangles.Count == 0)
return double.PositiveInfinity;
var actual = TriangleBox(triangles);
var expectedDiagonal = Math.Max(1.0, Math.Sqrt(
Math.Pow(expected.XMax - expected.XMin, 2.0) +
Math.Pow(expected.YMax - expected.YMin, 2.0) +
Math.Pow(expected.ZMax - expected.ZMin, 2.0)));
var cornerDelta =
Math.Abs(actual.XMin - expected.XMin) + Math.Abs(actual.YMin - expected.YMin) + Math.Abs(actual.ZMin - expected.ZMin) +
Math.Abs(actual.XMax - expected.XMax) + Math.Abs(actual.YMax - expected.YMax) + Math.Abs(actual.ZMax - expected.ZMax);
var separation =
Math.Max(0, Math.Max(expected.XMin - actual.XMax, actual.XMin - expected.XMax)) +
Math.Max(0, Math.Max(expected.YMin - actual.YMax, actual.YMin - expected.YMax)) +
Math.Max(0, Math.Max(expected.ZMin - actual.ZMax, actual.ZMin - expected.ZMax));
return cornerDelta / expectedDiagonal + separation * 10.0 / expectedDiagonal;
}
static Box TriangleBox(IReadOnlyList<FaceTriangleMm> triangles)
{
var points = triangles.SelectMany(triangle => new[] { triangle.A, triangle.B, triangle.C }).ToList();
return new Box(
points.Min(point => point[0]),
points.Min(point => point[1]),
points.Min(point => point[2]),
points.Max(point => point[0]),
points.Max(point => point[1]),
points.Max(point => point[2]));
}
static double TriangleAreaMm2(double[] a, double[] b, double[] c) =>
Vec.Norm(Vec.Cross(Vec.Sub(b, a), Vec.Sub(c, a))) * 0.5;
static void EnsureFaceTessellation(FaceEvidence face)
{
if (face.TessellationAttempted)
return;
face.TessellationAttempted = true;
if (face.RuntimeFace == null)
return;
try
{
var tessellation = ExtractFaceTessellation(face.RuntimeFace, face.AssemblyTransformSnapshot, expectedAssemblyBox: face.Box);
face.SamplePointsMm = tessellation.SamplePointsMm;
face.TrianglesMm = tessellation.TrianglesMm;
}
catch
{
face.SamplePointsMm = [];
face.TrianglesMm = [];
}
}
static AssemblyFrameEvidence InferAssemblyFrame(List<ComponentEvidence> components, double[] shaftAxis, double[]? upAxis)
{
if (upAxis is { Length: >= 3 })
{
return new AssemblyFrameEvidence
{
ShaftAxisMm = Vec.Round(Vec.Normalize(shaftAxis)),
VerticalAxisMm = Vec.Round(Vec.Normalize(upAxis)),
Inference = "vertical axis configured by --up-axis"
};
}
double[] vertical = [0.0, 1.0, 0.0];
var upper = components.FirstOrDefault(c => ContainsAny(c.Name, "\u4e0a\u7bb1\u4f53", "\u4e0a"));
var lower = components.FirstOrDefault(c => ContainsAny(c.Name, "\u4e0b\u7bb1\u4f53", "\u4e0b"));
if (upper?.BoundingBox != null && lower?.BoundingBox != null)
{
var delta = Vec.Sub(BoxCenter(upper.BoundingBox.Value), BoxCenter(lower.BoundingBox.Value));
int axisIndex = AbsMaxIndex(delta);
vertical = UnitAxis(axisIndex, Math.Sign(delta[axisIndex]) == 0 ? 1.0 : Math.Sign(delta[axisIndex]));
}
else
{
var spans = ComponentBBox(components);
var candidates = new[]
{
new { Axis = new[] { 1.0, 0.0, 0.0 }, Span = spans.XMax - spans.XMin },
new { Axis = new[] { 0.0, 1.0, 0.0 }, Span = spans.YMax - spans.YMin },
new { Axis = new[] { 0.0, 0.0, 1.0 }, Span = spans.ZMax - spans.ZMin }
};
vertical = candidates
.OrderBy(c => Math.Abs(Vec.Dot(c.Axis, shaftAxis)))
.ThenByDescending(c => c.Span)
.First()
.Axis;
}
return new AssemblyFrameEvidence
{
ShaftAxisMm = Vec.Round(Vec.Normalize(shaftAxis)),
VerticalAxisMm = Vec.Round(Vec.Normalize(vertical)),
Inference = upper != null && lower != null
? "vertical axis inferred from upper/lower housing component positions"
: "vertical axis inferred from global axis least aligned with main shaft axis"
};
}
static List<AssemblyFaceContactEvidence> BuildAssemblyFaceContacts(
List<ComponentEvidence> components,
AssemblyFrameEvidence frame,
Action<string, string> log)
{
var contacts = new List<AssemblyFaceContactEvidence>();
const double boxToleranceMm = 1.0;
var candidatePairs = new List<(ComponentEvidence A, ComponentEvidence B)>();
for (int i = 0; i < components.Count; i++)
{
var a = components[i];
for (int j = i + 1; j < components.Count; j++)
{
var b = components[j];
if (a.BoundingBox.HasValue && b.BoundingBox.HasValue && !BoxesOverlap(a.BoundingBox.Value, b.BoundingBox.Value, 5.0))
continue;
candidatePairs.Add((a, b));
}
}
for (var pairIndex = 0; pairIndex < candidatePairs.Count; pairIndex++)
{
var (a, b) = candidatePairs[pairIndex];
var pairCylinderCandidates = 0;
var pairCylinderCheapCandidates = 0;
var pairCylinderConfirmed = 0;
var pairCylinderRejectReasons = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
log("verify_trimmed_contact_pair",
$"pair={pairIndex + 1}/{candidatePairs.Count}; a={a.Name}; b={b.Name}; faces={a.Faces.Count}x{b.Faces.Count}; confirmed={contacts.Count}");
try
{
foreach (var af in a.Faces)
foreach (var bf in b.Faces)
{
var faceBoxesOverlap = BoxesOverlap(af.Box, bf.Box, boxToleranceMm);
if (faceBoxesOverlap)
{
var plane = TryBuildPlanarContact(a, af, b, bf, frame);
if (plane != null)
{
contacts.Add(plane);
continue;
}
}
var bothCylinders = af.Kind == "cylinder" && bf.Kind == "cylinder";
if (bothCylinders)
pairCylinderCandidates++;
var cylindricalCandidate = IsPotentialCylindricalContactCandidate(af, bf);
if (cylindricalCandidate)
pairCylinderCheapCandidates++;
if (bothCylinders && (faceBoxesOverlap || cylindricalCandidate))
{
var cylinder = TryBuildCylindricalContactWithReason(a, af, b, bf, frame, out var cylinderRejectReason);
if (cylinder != null)
{
contacts.Add(cylinder);
pairCylinderConfirmed++;
if (cylindricalCandidate)
{
log("verify_cylindrical_contact_candidate_confirmed",
$"a={a.Name}:face_{af.FaceIndex}; b={b.Name}:face_{bf.FaceIndex}; radius={af.RadiusMm:F4}/{bf.RadiusMm:F4}mm; axis_distance={AxisDistanceMm(af.AxisPointMm!, af.Axis!, bf.AxisPointMm!):F4}mm; axial_overlap={cylinder.AxialOverlapMm:F3}mm; method={cylinder.VerificationMethod}");
}
}
else if (!string.IsNullOrWhiteSpace(cylinderRejectReason))
{
pairCylinderRejectReasons[cylinderRejectReason] = pairCylinderRejectReasons.GetValueOrDefault(cylinderRejectReason) + 1;
if (cylindricalCandidate)
{
log("verify_cylindrical_contact_candidate_rejected",
$"a={a.Name}:face_{af.FaceIndex}; b={b.Name}:face_{bf.FaceIndex}; radius={af.RadiusMm:F4}/{bf.RadiusMm:F4}mm; axis_distance={AxisDistanceMm(af.AxisPointMm!, af.Axis!, bf.AxisPointMm!):F4}mm; reason={cylinderRejectReason}");
}
}
}
}
}
catch (Exception ex)
{
log("verify_trimmed_contact_pair_failed", $"a={a.Name}; b={b.Name}; {ex.Message}");
}
if (pairCylinderCandidates > 0 || pairCylinderCheapCandidates > 0 || pairCylinderConfirmed > 0)
{
log("verify_cylindrical_contact_pair_summary",
$"pair={pairIndex + 1}/{candidatePairs.Count}; a={a.Name}; b={b.Name}; cylinder_pairs={pairCylinderCandidates}; cheap_candidates={pairCylinderCheapCandidates}; confirmed={pairCylinderConfirmed}; rejects={string.Join(",", pairCylinderRejectReasons.Select(item => $"{item.Key}:{item.Value}"))}; total_confirmed={contacts.Count}");
}
}
log("verify_trimmed_contacts_completed", $"component_pairs={candidatePairs.Count}; confirmed_contacts={contacts.Count}");
return contacts
.OrderBy(c => c.ComponentA, StringComparer.OrdinalIgnoreCase)
.ThenBy(c => c.ComponentB, StringComparer.OrdinalIgnoreCase)
.ThenByDescending(c => c.Confidence)
.Take(500)
.ToList();
}
static Dictionary<string, FaceProcessAnnotation> BuildContactProcessLookup(List<AssemblyFaceContactEvidence> contacts)
{
var result = new Dictionary<string, FaceProcessAnnotation>(StringComparer.OrdinalIgnoreCase);
foreach (var contact in contacts)
{
Add(contact.ComponentA, contact.FaceA, new FaceProcessAnnotation
{
SurfaceProcessRole = contact.SurfaceProcessRoleA,
ProcessConfidence = contact.ProcessConfidenceA,
ProcessBasis = contact.ProcessBasisA,
MachiningScope = contact.MachiningScopeA
});
Add(contact.ComponentB, contact.FaceB, new FaceProcessAnnotation
{
SurfaceProcessRole = contact.SurfaceProcessRoleB,
ProcessConfidence = contact.ProcessConfidenceB,
ProcessBasis = contact.ProcessBasisB,
MachiningScope = contact.MachiningScopeB
});
}
return result;
void Add(string component, int faceIndex, FaceProcessAnnotation annotation)
{
var key = FaceKey(component, faceIndex);
if (!result.TryGetValue(key, out var existing) || ProcessRank(annotation.ProcessConfidence) > ProcessRank(existing.ProcessConfidence))
result[key] = annotation;
}
}
static List<AssemblyFaceEvidence> BuildAssemblyFaces(List<ComponentEvidence> components, AssemblyFrameEvidence frame, Dictionary<string, FaceProcessAnnotation> contactProcessLookup)
{
return components
.SelectMany(component => component.Faces.Select(face =>
{
var standaloneProcess = ClassifyStandaloneProcessRole(component, face, frame);
var process = contactProcessLookup.TryGetValue(FaceKey(component.Name, face.FaceIndex), out var contactProcess)
? contactProcess
: standaloneProcess;
return new AssemblyFaceEvidence
{
ComponentName = component.Name,
ComponentDisplayName = ComponentDisplayName(component.Name, component.Path),
ComponentPath = component.Path,
ComponentFileName = string.IsNullOrWhiteSpace(component.Path) ? "" : Path.GetFileName(component.Path),
ComponentCategory = component.Category,
FaceIndex = face.FaceIndex,
FaceKind = face.Kind,
AreaMm2 = Math.Round(face.AreaMm2, 3),
CenterMm = Vec.Round(face.CenterMm),
BBoxMm = face.Box.ToArrayRounded(),
Normal = Vec.Round(face.Normal),
RootPointMm = Vec.Round(face.RootPointMm),
AxisPointMm = Vec.Round(face.AxisPointMm),
Axis = Vec.Round(face.Axis),
RadiusMm = Math.Round(face.RadiusMm, 4),
DiameterMm = Math.Round(face.RadiusMm * 2.0, 4),
OrientationRole = ClassifyFaceOrientation(face, frame),
FunctionalRole = ClassifyFaceFunctionalRole(component, face, frame),
SurfaceProcessRole = process.SurfaceProcessRole,
ProcessConfidence = process.ProcessConfidence,
ProcessBasis = process.ProcessBasis,
MachiningScope = process.MachiningScope
};
}))
.OrderBy(f => f.ComponentName, StringComparer.OrdinalIgnoreCase)
.ThenBy(f => f.FaceIndex)
.ToList();
}
static AssemblyFaceContactEvidence? TryBuildPlanarContact(ComponentEvidence a, FaceEvidence af, ComponentEvidence b, FaceEvidence bf, AssemblyFrameEvidence frame)
{
if (af.Kind != "plane" || bf.Kind != "plane" || af.Normal == null || bf.Normal == null || af.RootPointMm == null || bf.RootPointMm == null)
return null;
var normalDot = Vec.Dot(af.Normal, bf.Normal);
if (Math.Abs(normalDot) < 0.985)
return null;
var gap = Math.Abs(Vec.Dot(Vec.Sub(bf.RootPointMm, af.RootPointMm), af.Normal));
if (gap > 0.15)
return null;
var overlap = EstimateProjectedBoxOverlap(af.Box, bf.Box, af.Normal);
if (overlap.AreaMm2 < 1.0)
return null;
var trimmedVerification = VerifyPlanarTrimmedPatchOverlap(af, bf);
if (!trimmedVerification.Verified)
return null;
var opposingBonus = normalDot < -0.985 ? 0.1 : 0.0;
var confidence = Math.Clamp(0.78 + opposingBonus - gap * 0.2 + Math.Min(trimmedVerification.OverlapMeasure, 2500.0) / 25000.0, 0.0, 0.97);
return new AssemblyFaceContactEvidence
{
Id = $"contact_{SanitizeId(a.Name)}_face_{af.FaceIndex}__{SanitizeId(b.Name)}_face_{bf.FaceIndex}",
ContactKind = "planar_face_contact_candidate",
ComponentA = a.Name,
ComponentDisplayNameA = ComponentDisplayName(a.Name, a.Path),
ComponentPathA = a.Path,
ComponentFileNameA = string.IsNullOrWhiteSpace(a.Path) ? "" : Path.GetFileName(a.Path),
ComponentCategoryA = a.Category,
FaceA = af.FaceIndex,
FaceKindA = af.Kind,
ComponentB = b.Name,
ComponentDisplayNameB = ComponentDisplayName(b.Name, b.Path),
ComponentPathB = b.Path,
ComponentFileNameB = string.IsNullOrWhiteSpace(b.Path) ? "" : Path.GetFileName(b.Path),
ComponentCategoryB = b.Category,
FaceB = bf.FaceIndex,
FaceKindB = bf.Kind,
MateRole = ClassifyMateRole(a, af, b, bf, "planar", frame),
FaceRoleA = ClassifyContactFaceRole(a, af, "planar", frame),
FaceRoleB = ClassifyContactFaceRole(b, bf, "planar", frame),
OrientationRoleA = ClassifyFaceOrientation(af, frame),
OrientationRoleB = ClassifyFaceOrientation(bf, frame),
SurfaceProcessRoleA = ClassifyContactProcessRole(a, af, "planar", ClassifyMateRole(a, af, b, bf, "planar", frame)).SurfaceProcessRole,
SurfaceProcessRoleB = ClassifyContactProcessRole(b, bf, "planar", ClassifyMateRole(a, af, b, bf, "planar", frame)).SurfaceProcessRole,
ProcessConfidenceA = ClassifyContactProcessRole(a, af, "planar", ClassifyMateRole(a, af, b, bf, "planar", frame)).ProcessConfidence,
ProcessConfidenceB = ClassifyContactProcessRole(b, bf, "planar", ClassifyMateRole(a, af, b, bf, "planar", frame)).ProcessConfidence,
ProcessBasisA = ClassifyContactProcessRole(a, af, "planar", ClassifyMateRole(a, af, b, bf, "planar", frame)).ProcessBasis,
ProcessBasisB = ClassifyContactProcessRole(b, bf, "planar", ClassifyMateRole(a, af, b, bf, "planar", frame)).ProcessBasis,
MachiningScopeA = ClassifyContactProcessRole(a, af, "planar", ClassifyMateRole(a, af, b, bf, "planar", frame)).MachiningScope,
MachiningScopeB = ClassifyContactProcessRole(b, bf, "planar", ClassifyMateRole(a, af, b, bf, "planar", frame)).MachiningScope,
GapMm = Math.Round(gap, 4),
NormalDot = Math.Round(normalDot, 4),
OverlapMeasureMm2 = Math.Round(trimmedVerification.OverlapMeasure, 3),
TrimmedPatchVerified = true,
VerificationStatus = "trimmed_interface_confirmed",
VerificationMethod = trimmedVerification.Method,
VerificationSampleHits = trimmedVerification.SampleHits,
VerificationMinDistanceMm = Math.Round(trimmedVerification.MinDistanceMm, 4),
Confidence = Math.Round(confidence, 3),
Evidence = "coplanar/opposite planar faces with verified overlap between tessellated trimmed face domains",
CenterA = Vec.Round(af.CenterMm),
CenterB = Vec.Round(bf.CenterMm),
NormalA = Vec.Round(af.Normal),
NormalB = Vec.Round(bf.Normal),
BBoxA = af.Box.ToArrayRounded(),
BBoxB = bf.Box.ToArrayRounded(),
RuntimeFaceA = af.RuntimeFace,
RuntimeFaceB = bf.RuntimeFace
};
}
static AssemblyFaceContactEvidence? TryBuildCylindricalContact(ComponentEvidence a, FaceEvidence af, ComponentEvidence b, FaceEvidence bf, AssemblyFrameEvidence frame) =>
TryBuildCylindricalContactWithReason(a, af, b, bf, frame, out _);
static AssemblyFaceContactEvidence? TryBuildCylindricalContactWithReason(ComponentEvidence a, FaceEvidence af, ComponentEvidence b, FaceEvidence bf, AssemblyFrameEvidence frame, out string rejectReason)
{
rejectReason = "";
if (af.Kind != "cylinder" || bf.Kind != "cylinder" ||
af.Axis == null || bf.Axis == null || af.AxisPointMm == null || bf.AxisPointMm == null)
{
rejectReason = "not_cylinder_or_axis_missing";
return null;
}
var axisDotAbs = Math.Abs(Vec.Dot(af.Axis, bf.Axis));
if (axisDotAbs < 0.9999)
{
rejectReason = "axis_not_parallel";
return null;
}
var axisDistance = AxisDistanceMm(af.AxisPointMm, af.Axis, bf.AxisPointMm);
var radiusGap = Math.Abs(af.RadiusMm - bf.RadiusMm);
if (axisDistance > 0.15 || radiusGap > 0.15)
{
rejectReason = "axis_or_radius_gap";
return null;
}
var ar = ProjectBoxRange(af.Box, af.Axis);
var br = ProjectBoxRange(bf.Box, af.Axis);
var axialOverlap = Math.Min(ar.Max, br.Max) - Math.Max(ar.Min, br.Min);
if (axialOverlap <= 0.05)
{
rejectReason = "axial_range_no_overlap";
return null;
}
var trimmedVerification = VerifyCylindricalTrimmedParamDomainOverlap(af, bf);
if (!trimmedVerification.Verified)
{
rejectReason = trimmedVerification.Method;
return null;
}
var confidence = Math.Clamp(0.80 - axisDistance * 0.15 - radiusGap * 0.15 + Math.Min(axialOverlap, 50.0) / 500.0, 0.0, 0.98);
return new AssemblyFaceContactEvidence
{
Id = $"contact_{SanitizeId(a.Name)}_face_{af.FaceIndex}__{SanitizeId(b.Name)}_face_{bf.FaceIndex}",
ContactKind = "cylindrical_coaxial_fit_candidate",
ComponentA = a.Name,
ComponentDisplayNameA = ComponentDisplayName(a.Name, a.Path),
ComponentPathA = a.Path,
ComponentFileNameA = string.IsNullOrWhiteSpace(a.Path) ? "" : Path.GetFileName(a.Path),
ComponentCategoryA = a.Category,
FaceA = af.FaceIndex,
FaceKindA = af.Kind,
ComponentB = b.Name,
ComponentDisplayNameB = ComponentDisplayName(b.Name, b.Path),
ComponentPathB = b.Path,
ComponentFileNameB = string.IsNullOrWhiteSpace(b.Path) ? "" : Path.GetFileName(b.Path),
ComponentCategoryB = b.Category,
FaceB = bf.FaceIndex,
FaceKindB = bf.Kind,
MateRole = ClassifyMateRole(a, af, b, bf, "cylindrical", frame),
FaceRoleA = ClassifyContactFaceRole(a, af, "cylindrical", frame),
FaceRoleB = ClassifyContactFaceRole(b, bf, "cylindrical", frame),
OrientationRoleA = ClassifyFaceOrientation(af, frame),
OrientationRoleB = ClassifyFaceOrientation(bf, frame),
SurfaceProcessRoleA = ClassifyContactProcessRole(a, af, "cylindrical", ClassifyMateRole(a, af, b, bf, "cylindrical", frame)).SurfaceProcessRole,
SurfaceProcessRoleB = ClassifyContactProcessRole(b, bf, "cylindrical", ClassifyMateRole(a, af, b, bf, "cylindrical", frame)).SurfaceProcessRole,
ProcessConfidenceA = ClassifyContactProcessRole(a, af, "cylindrical", ClassifyMateRole(a, af, b, bf, "cylindrical", frame)).ProcessConfidence,
ProcessConfidenceB = ClassifyContactProcessRole(b, bf, "cylindrical", ClassifyMateRole(a, af, b, bf, "cylindrical", frame)).ProcessConfidence,
ProcessBasisA = ClassifyContactProcessRole(a, af, "cylindrical", ClassifyMateRole(a, af, b, bf, "cylindrical", frame)).ProcessBasis,
ProcessBasisB = ClassifyContactProcessRole(b, bf, "cylindrical", ClassifyMateRole(a, af, b, bf, "cylindrical", frame)).ProcessBasis,
MachiningScopeA = ClassifyContactProcessRole(a, af, "cylindrical", ClassifyMateRole(a, af, b, bf, "cylindrical", frame)).MachiningScope,
MachiningScopeB = ClassifyContactProcessRole(b, bf, "cylindrical", ClassifyMateRole(a, af, b, bf, "cylindrical", frame)).MachiningScope,
GapMm = Math.Round(Math.Max(axisDistance, radiusGap), 4),
NormalDot = Math.Round(double.IsFinite(trimmedVerification.RepresentativeNormalDot) ? trimmedVerification.RepresentativeNormalDot : axisDotAbs, 4),
RadiusAmm = Math.Round(af.RadiusMm, 4),
RadiusBmm = Math.Round(bf.RadiusMm, 4),
AxisDistanceMm = Math.Round(axisDistance, 4),
AxialOverlapMm = Math.Round(axialOverlap, 3),
OverlapMeasureMm2 = Math.Round(trimmedVerification.OverlapMeasure, 3),
TrimmedPatchVerified = true,
VerificationStatus = trimmedVerification.Status,
VerificationMethod = trimmedVerification.Method,
VerificationSampleHits = trimmedVerification.SampleHits,
VerificationMinDistanceMm = Math.Round(trimmedVerification.MinDistanceMm, 4),
Confidence = Math.Round(confidence, 3),
Evidence = "coaxial cylindrical faces with compatible radius and at least one 2 mm axial slice whose SolidWorks-trimmed angular intervals intersect",
CenterA = Vec.Round(af.CenterMm),
CenterB = Vec.Round(bf.CenterMm),
AxisA = Vec.Round(af.Axis),
AxisB = Vec.Round(bf.Axis),
BBoxA = af.Box.ToArrayRounded(),
BBoxB = bf.Box.ToArrayRounded(),
RuntimeFaceA = af.RuntimeFace,
RuntimeFaceB = bf.RuntimeFace
};
}
static bool IsPotentialCylindricalContactCandidate(FaceEvidence af, FaceEvidence bf)
{
if (af.Kind != "cylinder" || bf.Kind != "cylinder" ||
af.Axis == null || bf.Axis == null || af.AxisPointMm == null || bf.AxisPointMm == null)
{
return false;
}
var axisDotAbs = Math.Abs(Vec.Dot(af.Axis, bf.Axis));
if (axisDotAbs < 0.9999)
return false;
var axisDistance = AxisDistanceMm(af.AxisPointMm, af.Axis, bf.AxisPointMm);
var radiusGap = Math.Abs(af.RadiusMm - bf.RadiusMm);
if (axisDistance > 0.15 || radiusGap > 0.15)
return false;
var ar = ProjectBoxRange(af.Box, af.Axis);
var br = ProjectBoxRange(bf.Box, af.Axis);
var axialOverlap = Math.Min(ar.Max, br.Max) - Math.Max(ar.Min, br.Min);
return axialOverlap > 0.05;
}
static string ClassifyMateRole(ComponentEvidence a, FaceEvidence af, ComponentEvidence b, FaceEvidence bf, string contactKind, AssemblyFrameEvidence frame)
{
bool aHousing = a.Category == "housing";
bool bHousing = b.Category == "housing";
bool aEndCap = a.Category == "end_cap";
bool bEndCap = b.Category == "end_cap";
bool aInspection = a.Category == "inspection_cover";
bool bInspection = b.Category == "inspection_cover";
if (contactKind == "cylindrical")
{
if ((aEndCap && bHousing) || (bEndCap && aHousing))
return "end_cap_register_fit";
if (aHousing && bHousing)
return "split_housing_bearing_seat_alignment";
return "cylindrical_fit";
}
if (aHousing && bHousing)
return "housing_split_joint";
if ((aEndCap && bHousing) || (bEndCap && aHousing))
return "end_cap_mounting_face";
if ((aInspection && bHousing) || (bInspection && aHousing))
return "inspection_cover_mounting_face";
return "planar_mating_face";
}
static FaceProcessAnnotation ClassifyContactProcessRole(ComponentEvidence component, FaceEvidence face, string contactKind, string mateRole)
{
return mateRole switch
{
"end_cap_register_fit" => new FaceProcessAnnotation
{
SurfaceProcessRole = "precision_machined_mating_surface",
ProcessConfidence = "high",
ProcessBasis = "end cap register fit: functional locating cylindrical interface",
MachiningScope = "full_contact_face"
},
"split_housing_bearing_seat_alignment" => new FaceProcessAnnotation
{
SurfaceProcessRole = "precision_machined_bearing_seat_surface",
ProcessConfidence = "high",
ProcessBasis = "split housing bearing seat alignment: bearing-seat bore is a precision machined surface",
MachiningScope = "full_contact_face"
},
"housing_split_joint" => new FaceProcessAnnotation
{
SurfaceProcessRole = "machined_split_joint_surface",
ProcessConfidence = "high",
ProcessBasis = "upper/lower housing split joint requires flatness and repeatable assembly accuracy",
MachiningScope = "full_contact_face_or_local_joint_pad"
},
"end_cap_mounting_face" => new FaceProcessAnnotation
{
SurfaceProcessRole = "machined_mounting_surface",
ProcessConfidence = "high",
ProcessBasis = "end cap flange mounting face: functional support/sealing/positioning interface",
MachiningScope = "contact_patch_or_local_boss"
},
"inspection_cover_mounting_face" => new FaceProcessAnnotation
{
SurfaceProcessRole = "machined_cover_mounting_surface",
ProcessConfidence = "medium_high",
ProcessBasis = "inspection cover mounting face: cover support/sealing interface; local pad preferred over full large surface",
MachiningScope = "contact_patch_or_local_boss"
},
"cylindrical_fit" => new FaceProcessAnnotation
{
SurfaceProcessRole = "machined_cylindrical_fit_surface",
ProcessConfidence = "medium_high",
ProcessBasis = "coaxial cylindrical fit candidate",
MachiningScope = "full_contact_face"
},
_ => new FaceProcessAnnotation
{
SurfaceProcessRole = "machined_surface_candidate",
ProcessConfidence = "medium",
ProcessBasis = $"{mateRole}: contact is evidence, but process role needs tolerance/PMI or functional confirmation",
MachiningScope = "contact_patch_unknown"
}
};
}
static FaceProcessAnnotation ClassifyStandaloneProcessRole(ComponentEvidence component, FaceEvidence face, AssemblyFrameEvidence frame)
{
var functionalRole = ClassifyFaceFunctionalRole(component, face, frame);
var orientation = ClassifyFaceOrientation(face, frame);
if (functionalRole is "housing_bearing_seat_or_bore" or "end_cap_register_or_bore")
{
return new FaceProcessAnnotation
{
SurfaceProcessRole = "machined_surface_candidate",
ProcessConfidence = "medium",
ProcessBasis = $"{functionalRole}: precision role inferred from geometry/category, but no explicit contact or PMI evidence",
MachiningScope = "full_functional_face"
};
}
if (component.Category == "housing" && face.Kind == "plane" && orientation == "side_face")
{
return new FaceProcessAnnotation
{
SurfaceProcessRole = "unmachined_cast_surface_candidate",
ProcessConfidence = "medium",
ProcessBasis = "large housing side face without contact/fit evidence is likely cast surface rather than machined surface",
MachiningScope = "not_applicable"
};
}
if (component.Category == "housing" && face.Kind == "plane" && orientation is "upward_facing_face" or "downward_facing_face")
{
return new FaceProcessAnnotation
{
SurfaceProcessRole = "unknown_process_surface",
ProcessConfidence = "low",
ProcessBasis = "horizontal housing face may contain local machined pads, but full-face machining cannot be inferred without contact/pad evidence",
MachiningScope = "unknown_local_pad_or_full_face"
};
}
if (face.Kind == "cylinder" && face.RadiusMm > 0)
{
return new FaceProcessAnnotation
{
SurfaceProcessRole = "machined_feature_candidate",
ProcessConfidence = "low",
ProcessBasis = "cylindrical hole/boss feature is often produced by machining, but functional precision role is not confirmed",
MachiningScope = "feature_surface"
};
}
return new FaceProcessAnnotation
{
SurfaceProcessRole = "unknown_process_surface",
ProcessConfidence = "low",
ProcessBasis = "no contact, fit, support, sealing, locating, tolerance or PMI evidence",
MachiningScope = "unknown"
};
}
static string FaceKey(string componentName, int faceIndex) => $"{componentName}|{faceIndex}";
static int ProcessRank(string confidence) => confidence switch
{
"high" => 4,
"medium_high" => 3,
"medium" => 2,
"low" => 1,
_ => 0
};
static string ClassifyContactFaceRole(ComponentEvidence component, FaceEvidence face, string contactKind, AssemblyFrameEvidence frame)
{
if (contactKind == "cylindrical")
{
if (component.Category == "end_cap")
return "end_cap_register_cylindrical_face";
if (component.Category == "housing")
return "housing_bore_or_register_cylindrical_face";
return "cylindrical_mating_face";
}
var orientation = ClassifyFaceOrientation(face, frame);
if (component.Category == "end_cap")
return orientation == "downward_facing_face" ? "end_cap_bottom_mating_face" : "end_cap_planar_mating_face";
if (component.Category == "inspection_cover")
return orientation == "downward_facing_face" ? "cover_bottom_mating_face" : "cover_planar_mating_face";
if (component.Category == "housing")
return orientation switch
{
"upward_facing_face" => "housing_top_or_support_face",
"downward_facing_face" => "housing_bottom_or_split_face",
"side_face" => "housing_side_face",
"axial_end_face" => "housing_axial_end_face",
_ => "housing_planar_mating_face"
};
return "planar_mating_face";
}
static string ClassifyFaceFunctionalRole(ComponentEvidence component, FaceEvidence face, AssemblyFrameEvidence frame)
{
if (face.Kind == "cylinder")
{
if (component.Category == "end_cap" && IsParallel(face.Axis, frame.ShaftAxisMm, 0.85))
return "end_cap_register_or_bore";
if (component.Category == "housing" && IsParallel(face.Axis, frame.ShaftAxisMm, 0.85))
return "housing_bearing_seat_or_bore";
return "cylindrical_feature";
}
if (face.Kind == "plane")
{
var orientation = ClassifyFaceOrientation(face, frame);
if (component.Category == "inspection_cover" && orientation == "downward_facing_face")
return "cover_bottom_mounting_face";
if (component.Category == "end_cap" && orientation == "axial_end_face")
return "end_cap_axial_mounting_face";
if (component.Category == "housing" && orientation == "side_face")
return "housing_side_wall_face";
if (component.Category == "housing" && orientation is "upward_facing_face" or "downward_facing_face")
return "housing_horizontal_face";
return "planar_feature";
}
return "unknown";
}
static string ClassifyFaceOrientation(FaceEvidence face, AssemblyFrameEvidence frame)
{
if (face.Kind == "plane" && face.Normal != null)
{
var vertical = Vec.Normalize(frame.VerticalAxisMm);
var shaft = Vec.Normalize(frame.ShaftAxisMm);
var vDot = Vec.Dot(face.Normal, vertical);
if (vDot > 0.85)
return "upward_facing_face";
if (vDot < -0.85)
return "downward_facing_face";
if (Math.Abs(Vec.Dot(face.Normal, shaft)) > 0.85)
return "axial_end_face";
return "side_face";
}
if (face.Kind == "cylinder" && face.Axis != null)
{
if (IsParallel(face.Axis, frame.ShaftAxisMm, 0.85))
return "shaft_axis_cylindrical_face";
if (IsParallel(face.Axis, frame.VerticalAxisMm, 0.85))
return "vertical_axis_cylindrical_face";
return "side_axis_cylindrical_face";
}
return "unknown";
}
static bool IsParallel(double[]? a, double[]? b, double tolerance)
{
if (a == null || b == null || a.Length < 3 || b.Length < 3)
return false;
return Math.Abs(Vec.Dot(Vec.Normalize(a), Vec.Normalize(b))) >= tolerance;
}
static List<AxisGroupEvidence> BuildAxisGroups(List<ComponentEvidence> components)
{
var cylinders = components.SelectMany(c => c.Cylinders.Select(f => (Component: c, Face: f))).ToList();
var groups = new List<AxisGroupEvidence>();
var used = new HashSet<string>();
int index = 0;
for (int i = 0; i < cylinders.Count; i++)
{
var seed = cylinders[i];
var key = $"{seed.Component.Name}:{seed.Face.FaceIndex}";
if (used.Contains(key))
continue;
var members = new List<(ComponentEvidence Component, FaceEvidence Face)> { seed };
used.Add(key);
for (int j = i + 1; j < cylinders.Count; j++)
{
var candidate = cylinders[j];
var candidateKey = $"{candidate.Component.Name}:{candidate.Face.FaceIndex}";
if (used.Contains(candidateKey))
continue;
if (AreSameAxis(seed.Face, candidate.Face))
{
members.Add(candidate);
used.Add(candidateKey);
}
}
index++;
groups.Add(new AxisGroupEvidence
{
GroupId = $"axis_{index}",
AxisPointMm = Vec.Round(seed.Face.AxisPointMm),
Axis = Vec.Round(seed.Face.Axis),
CylinderCount = members.Count,
ComponentCount = members.Select(m => m.Component.Name).Distinct(StringComparer.OrdinalIgnoreCase).Count(),
RadiusMinMm = Math.Round(members.Min(m => m.Face.RadiusMm), 3),
RadiusMaxMm = Math.Round(members.Max(m => m.Face.RadiusMm), 3),
Members = members.Select(m => new AxisMemberEvidence
{
ComponentName = m.Component.Name,
ComponentCategory = m.Component.Category,
FaceIndex = m.Face.FaceIndex,
RadiusMm = Math.Round(m.Face.RadiusMm, 3),
AreaMm2 = Math.Round(m.Face.AreaMm2, 3)
}).ToList()
});
}
return groups.OrderByDescending(g => g.ComponentCount).ThenByDescending(g => g.CylinderCount).ToList();
}
static bool AreSameAxis(FaceEvidence a, FaceEvidence b)
{
if (a.Axis == null || b.Axis == null || a.AxisPointMm == null || b.AxisPointMm == null)
return false;
if (Math.Abs(Vec.Dot(a.Axis, b.Axis)) < 0.985)
return false;
var delta = Vec.Sub(a.AxisPointMm, b.AxisPointMm);
var along = Vec.Mul(a.Axis, Vec.Dot(delta, a.Axis));
var radial = Vec.Sub(delta, along);
var tolerance = Math.Max(1.0, Math.Min(a.RadiusMm, b.RadiusMm) * 0.08);
return Vec.Norm(radial) <= tolerance;
}
static bool BoxesOverlap(Box a, Box b, double toleranceMm)
{
return a.XMin <= b.XMax + toleranceMm && a.XMax + toleranceMm >= b.XMin &&
a.YMin <= b.YMax + toleranceMm && a.YMax + toleranceMm >= b.YMin &&
a.ZMin <= b.ZMax + toleranceMm && a.ZMax + toleranceMm >= b.ZMin;
}
static Box ComponentBBox(List<ComponentEvidence> components)
{
var boxes = components.Where(c => c.BoundingBox.HasValue).Select(c => c.BoundingBox!.Value).ToList();
if (boxes.Count == 0)
return new Box(0, 0, 0, 0, 0, 0);
return new Box(
boxes.Min(b => b.XMin),
boxes.Min(b => b.YMin),
boxes.Min(b => b.ZMin),
boxes.Max(b => b.XMax),
boxes.Max(b => b.YMax),
boxes.Max(b => b.ZMax));
}
static Box? ComponentBox(ComponentEvidence component)
{
if (component.Faces.Count == 0)
return null;
return FaceBox(component.Faces);
}
static Box? FaceBox(IReadOnlyList<FaceEvidence> faces)
{
if (faces.Count == 0)
return null;
return new Box(
faces.Min(f => f.Box.XMin),
faces.Min(f => f.Box.YMin),
faces.Min(f => f.Box.ZMin),
faces.Max(f => f.Box.XMax),
faces.Max(f => f.Box.YMax),
faces.Max(f => f.Box.ZMax));
}
static List<IndexedFace> SelectOuterEnvelopeIndexedFaces(
IReadOnlyList<IndexedFace> faces,
Box? componentBox,
dynamic mathUtility)
{
if (!componentBox.HasValue)
return [];
const double toleranceMm = 0.25;
return faces
.Where(face =>
{
var localBox = ToDoubleArray(Safe<object?>(() => face.Face.GetBox(), null))
.Take(6)
.Select(value => value * 1000.0)
.ToArray();
if (localBox.Length < 6)
return false;
var assemblyBox = face.OwnerComponent == null
? localBox
: TransformBBox(mathUtility, face.OwnerComponent, localBox);
return assemblyBox.Length >= 6 &&
TouchesOuterEnvelope(Box.FromArray(assemblyBox), componentBox.Value, toleranceMm);
})
.OrderBy(face => face.FaceIndex)
.ToList();
}
static bool TouchesOuterEnvelope(Box faceBox, Box componentBox, double toleranceMm)
{
return faceBox.XMin <= componentBox.XMin + toleranceMm ||
faceBox.YMin <= componentBox.YMin + toleranceMm ||
faceBox.ZMin <= componentBox.ZMin + toleranceMm ||
faceBox.XMax >= componentBox.XMax - toleranceMm ||
faceBox.YMax >= componentBox.YMax - toleranceMm ||
faceBox.ZMax >= componentBox.ZMax - toleranceMm;
}
static List<IndexedFace> SelectMultiViewVisibleFaces(
ModelDoc2 doc,
AssemblyDoc assembly,
Component2 target,
Box visibilityBox,
IReadOnlyList<IndexedFace> indexedFaces,
IReadOnlyList<Component2> visibilityGroupComponents,
string visibilityGroupName,
IDictionary<string, HashSet<IntPtr>> visibleFaceKeyCache,
Action<string, string> log)
{
if (indexedFaces.Count == 0)
return [];
var selectionManager = Safe(() => doc.SelectionManager as SelectionMgr, null);
if (selectionManager == null)
return [];
if (!visibleFaceKeyCache.TryGetValue(visibilityGroupName, out var visibleFaceKeys))
{
var originalVisibility = new Dictionary<Component2, int>();
visibleFaceKeys = [];
var rayCount = 0;
try
{
IsolateComponentsForVisibilitySampling(assembly, visibilityGroupComponents, originalVisibility);
doc.ClearSelection2(true);
doc.GraphicsRedraw2();
foreach (var direction in MultiViewDirections())
{
foreach (var ray in BuildVisibilitySampleRays(visibilityBox, direction))
{
rayCount++;
var faceKey = TrySelectVisibleFaceKeyByRay(doc, selectionManager, visibilityGroupComponents, ray.OriginMm, ray.Direction);
if (faceKey != IntPtr.Zero)
visibleFaceKeys.Add(faceKey);
}
}
}
catch (Exception ex)
{
log("visible_face_sampling_failed", $"{visibilityGroupName}; {ex.Message}");
}
finally
{
try { doc.ClearSelection2(true); } catch { }
RestoreComponentVisibility(originalVisibility);
}
visibleFaceKeyCache[visibilityGroupName] = visibleFaceKeys;
log("visible_face_sampling_completed", $"{visibilityGroupName}; directions=6; rays={rayCount}; visible_face_keys={visibleFaceKeys.Count}");
}
return indexedFaces
.Where(face =>
{
var key = ComIdentity(face.Face);
return key != IntPtr.Zero && visibleFaceKeys.Contains(key);
})
.OrderBy(face => face.FaceIndex)
.ToList();
}
static IntPtr TrySelectVisibleFaceKeyByRay(
ModelDoc2 doc,
SelectionMgr selectionManager,
IReadOnlyList<Component2> visibilityGroupComponents,
double[] originMm,
double[] direction)
{
try
{
doc.ClearSelection2(true);
var selected = doc.Extension.SelectByRay(
originMm[0] / 1000.0,
originMm[1] / 1000.0,
originMm[2] / 1000.0,
direction[0],
direction[1],
direction[2],
0.00075,
(int)swSelectType_e.swSelFACES,
false,
0,
0);
if (!selected || selectionManager.GetSelectedObjectCount2(-1) <= 0)
return IntPtr.Zero;
var selectedComponentObj = selectionManager.GetSelectedObjectsComponent4(1, -1);
if (selectedComponentObj is Component2 selectedComponent &&
!visibilityGroupComponents.Any(component => SameRuntimeComponent(selectedComponent, component)))
{
return IntPtr.Zero;
}
var selectedObject = selectionManager.GetSelectedObject6(1, -1);
if (selectedObject == null)
return IntPtr.Zero;
return ComIdentity(selectedObject);
}
catch
{
return IntPtr.Zero;
}
}
static IEnumerable<(double[] OriginMm, double[] Direction)> BuildVisibilitySampleRays(Box box, double[] viewDirection)
{
const int samplesPerAxis = 9;
const double marginMm = 30.0;
var d = Vec.Normalize(viewDirection);
var basis = RayPlaneBasis(d);
var corners = BoxCorners(box);
var uRange = ProjectionRange(corners, basis.U);
var vRange = ProjectionRange(corners, basis.V);
var dRange = ProjectionRange(corners, d);
var originDepth = dRange.Max + marginMm;
for (int i = 0; i < samplesPerAxis; i++)
{
var u = Lerp(uRange.Min, uRange.Max, samplesPerAxis == 1 ? 0.5 : i / (double)(samplesPerAxis - 1));
for (int j = 0; j < samplesPerAxis; j++)
{
var v = Lerp(vRange.Min, vRange.Max, samplesPerAxis == 1 ? 0.5 : j / (double)(samplesPerAxis - 1));
var origin = Vec.Add(Vec.Add(Vec.Mul(basis.U, u), Vec.Mul(basis.V, v)), Vec.Mul(d, originDepth));
yield return (origin, Vec.Mul(d, -1.0));
}
}
}
static List<double[]> MultiViewDirections() =>
[
[1.0, 0.0, 0.0],
[-1.0, 0.0, 0.0],
[0.0, 1.0, 0.0],
[0.0, -1.0, 0.0],
[0.0, 0.0, 1.0],
[0.0, 0.0, -1.0]
];
static (double[] U, double[] V) RayPlaneBasis(double[] direction)
{
var reference = Math.Abs(direction[2]) < 0.9 ? new[] { 0.0, 0.0, 1.0 } : [0.0, 1.0, 0.0];
var u = Vec.Normalize(Vec.Cross(reference, direction));
var v = Vec.Normalize(Vec.Cross(direction, u));
return (u, v);
}
static List<double[]> BoxCorners(Box box) =>
[
[box.XMin, box.YMin, box.ZMin],
[box.XMin, box.YMin, box.ZMax],
[box.XMin, box.YMax, box.ZMin],
[box.XMin, box.YMax, box.ZMax],
[box.XMax, box.YMin, box.ZMin],
[box.XMax, box.YMin, box.ZMax],
[box.XMax, box.YMax, box.ZMin],
[box.XMax, box.YMax, box.ZMax]
];
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 double Lerp(double a, double b, double t) => a + (b - a) * t;
static void IsolateComponentsForVisibilitySampling(
AssemblyDoc assembly,
IReadOnlyList<Component2> visibleComponents,
IDictionary<Component2, int> originalVisibility)
{
var allComponents = (assembly.GetComponents(false) as object[] ?? [])
.OfType<Component2>()
.ToList();
foreach (var component in allComponents)
{
int original = swComponentVisibilityState_e.swComponentUnknown.GetHashCode();
try { original = component.Visible; } catch { }
originalVisibility[component] = original;
component.Visible = visibleComponents.Any(visible => SameRuntimeComponent(component, visible))
? (int)swComponentVisibilityState_e.swComponentVisible
: (int)swComponentVisibilityState_e.swComponentHidden;
}
}
static void RestoreComponentVisibility(IDictionary<Component2, int> originalVisibility)
{
foreach (var pair in originalVisibility)
{
try
{
if (pair.Value != (int)swComponentVisibilityState_e.swComponentUnknown)
pair.Key.Visible = pair.Value;
}
catch
{
}
}
}
static bool SameRuntimeComponent(Component2 a, Component2 b)
{
if (ReferenceEquals(a, b))
return true;
var aKey = ComIdentity(a);
var bKey = ComIdentity(b);
if (aKey != IntPtr.Zero && bKey != IntPtr.Zero && aKey == bKey)
return true;
var aName = Safe(() => a.Name2, "");
var bName = Safe(() => b.Name2, "");
var aPath = Safe(() => a.GetPathName(), "");
var bPath = Safe(() => b.GetPathName(), "");
return SameComponent(aName, bName, aPath, bPath);
}
static IntPtr ComIdentity(object value)
{
IntPtr unknown = IntPtr.Zero;
try
{
unknown = Marshal.GetIUnknownForObject(value);
return unknown;
}
catch
{
return IntPtr.Zero;
}
finally
{
if (unknown != IntPtr.Zero)
Marshal.Release(unknown);
}
}
static Box? ComponentBoxFromComponent(Component2 component)
{
try
{
dynamic dyn = component;
var values = ToDoubleArray(dyn.GetBox(false, false)).Take(6).Select(v => v * 1000.0).ToArray();
if (values.Length < 6 || values.Any(v => double.IsNaN(v) || double.IsInfinity(v)))
return null;
return Box.FromArray(values);
}
catch
{
return null;
}
}
static double[] BoxCenter(Box box) =>
[
(box.XMin + box.XMax) / 2.0,
(box.YMin + box.YMax) / 2.0,
(box.ZMin + box.ZMax) / 2.0
];
static int AbsMaxIndex(double[] values)
{
int index = 0;
double best = Math.Abs(values[0]);
for (int i = 1; i < values.Length; i++)
{
var current = Math.Abs(values[i]);
if (current > best)
{
best = current;
index = i;
}
}
return index;
}
static double[] UnitAxis(int axisIndex, double sign)
{
var axis = new[] { 0.0, 0.0, 0.0 };
axis[Math.Clamp(axisIndex, 0, 2)] = sign >= 0 ? 1.0 : -1.0;
return axis;
}
static (double AreaMm2, double UOverlapMm, double VOverlapMm) EstimateProjectedBoxOverlap(Box a, Box b, double[] normal)
{
var (u, v) = PlaneBasis(normal);
var ar = ProjectBox2D(a, u, v);
var br = ProjectBox2D(b, u, v);
var uOverlap = Math.Min(ar.UMax, br.UMax) - Math.Max(ar.UMin, br.UMin);
var vOverlap = Math.Min(ar.VMax, br.VMax) - Math.Max(ar.VMin, br.VMin);
if (uOverlap <= 0 || vOverlap <= 0)
return (0, 0, 0);
return (uOverlap * vOverlap, uOverlap, vOverlap);
}
static (double[] U, double[] V) PlaneBasis(double[] normal)
{
var n = Vec.Normalize(normal);
var seed = Math.Abs(n[2]) < 0.9 ? new[] { 0.0, 0.0, 1.0 } : new[] { 0.0, 1.0, 0.0 };
var u = Vec.Normalize(Vec.Cross(seed, n));
var v = Vec.Normalize(Vec.Cross(n, u));
return (u, v);
}
static (double UMin, double UMax, double VMin, double VMax) ProjectBox2D(Box box, double[] u, double[] v)
{
var points = Corners(box).ToList();
var us = points.Select(p => Vec.Dot(p, u)).ToList();
var vs = points.Select(p => Vec.Dot(p, v)).ToList();
return (us.Min(), us.Max(), vs.Min(), vs.Max());
}
static TrimmedPatchVerification VerifyPlanarTrimmedPatchOverlap(FaceEvidence a, FaceEvidence b)
{
EnsureFaceTessellation(a);
EnsureFaceTessellation(b);
if (a.TrianglesMm.Count == 0 || b.TrianglesMm.Count == 0 || a.Normal == null)
return TrimmedPatchVerification.Unverified("planar_trimmed_mesh_unavailable");
var (u, v) = PlaneBasis(a.Normal);
var minimumOverlapArea = Math.Max(0.05, Math.Min(1.0, Math.Min(a.AreaMm2, b.AreaMm2) * 0.0005));
var overlapArea = 0.0;
var overlapPairs = 0;
var projectedA = a.TrianglesMm
.Select(triangle => ProjectTriangle2D(triangle, u, v))
.Select(polygon => (Polygon: polygon, Box: PolygonBox2D(polygon)))
.ToList();
var projectedB = b.TrianglesMm
.Select(triangle => ProjectTriangle2D(triangle, u, v))
.Select(polygon => (Polygon: polygon, Box: PolygonBox2D(polygon)))
.ToList();
foreach (var triangleA in projectedA)
{
foreach (var triangleB in projectedB)
{
if (!BoxesOverlap2D(triangleA.Box, triangleB.Box, 1e-6))
continue;
var area = ConvexPolygonIntersectionArea(triangleA.Polygon, triangleB.Polygon);
if (area <= 1e-8)
continue;
overlapArea += area;
overlapPairs++;
if (overlapArea >= minimumOverlapArea)
{
var minDistance = MinimumTessellationDistanceMm(a, b);
return new TrimmedPatchVerification(
true,
"trimmed_interface_confirmed",
"planar_tessellated_trimmed_domain_intersection",
overlapArea,
overlapPairs,
minDistance);
}
}
}
return new TrimmedPatchVerification(
false,
"extension_only_rejected",
"planar_tessellated_trimmed_domain_intersection",
overlapArea,
overlapPairs,
double.PositiveInfinity);
}
static TrimmedPatchVerification VerifyCylindricalTrimmedParamDomainOverlap(FaceEvidence a, FaceEvidence b)
{
if (a.Axis == null || a.AxisPointMm == null || b.Axis == null || b.AxisPointMm == null)
return TrimmedPatchVerification.Unverified("cylindrical_analytic_axis_unavailable");
var commonAxis = Vec.Normalize(a.Axis);
var commonOrigin = a.AxisPointMm;
if (!TryBuildCylindricalTrimmedParamDomain(a, commonOrigin, commonAxis, out var domainA) ||
!TryBuildCylindricalTrimmedParamDomain(b, commonOrigin, commonAxis, out var domainB))
{
return TrimmedPatchVerification.Unverified("cylindrical_trimmed_param_domain_unavailable");
}
var axialOverlap = Math.Min(domainA.AxialMaxMm, domainB.AxialMaxMm) -
Math.Max(domainA.AxialMinMm, domainB.AxialMinMm);
if (axialOverlap <= 0.05)
{
return new TrimmedPatchVerification(
false,
"trimmed_domain_rejected",
"cylindrical_trimmed_param_axial_ranges_do_not_overlap",
0,
0,
Math.Abs(a.RadiusMm - b.RadiusMm));
}
var normalDot = domainA.NormalSense * domainB.NormalSense;
var axialMin = Math.Max(domainA.AxialMinMm, domainB.AxialMinMm);
var axialMax = Math.Min(domainA.AxialMaxMm, domainB.AxialMaxMm);
var testedSlices = 0;
for (var bandStart = axialMin; bandStart < axialMax - 1e-9; bandStart += CylindricalParamDomainAxialStepMm)
{
var bandEnd = Math.Min(axialMax, bandStart + CylindricalParamDomainAxialStepMm);
if (bandEnd - bandStart <= 1e-9)
continue;
var sampleT = (bandStart + bandEnd) * 0.5;
testedSlices++;
var intervalsA = CylindricalAngularIntervalsAtT(domainA, sampleT);
if (intervalsA.Count == 0)
continue;
var intervalsB = CylindricalAngularIntervalsAtT(domainB, sampleT);
if (intervalsB.Count == 0 || !AngularIntervalsOverlap(intervalsA, intervalsB))
continue;
if (!double.IsFinite(normalDot) || normalDot > -0.5)
{
return new TrimmedPatchVerification(
false,
"trimmed_domain_rejected",
"cylindrical_trimmed_face_angular_intersection_but_normals_not_opposed",
0,
1,
Math.Abs(a.RadiusMm - b.RadiusMm),
normalDot);
}
return new TrimmedPatchVerification(
true,
"trimmed_cylindrical_domain_confirmed",
"cylindrical_trimmed_face_2mm_slice_angular_intersection",
0,
1,
Math.Abs(a.RadiusMm - b.RadiusMm),
normalDot);
}
return new TrimmedPatchVerification(
false,
"trimmed_domain_rejected",
testedSlices == 0
? "cylindrical_trimmed_face_no_testable_axial_slice"
: "cylindrical_trimmed_face_no_angular_intersection_in_2mm_slices",
0,
0,
Math.Abs(a.RadiusMm - b.RadiusMm),
normalDot);
}
static bool TryBuildCylindricalTrimmedParamDomain(
FaceEvidence face,
double[] commonOriginMm,
double[] commonAxis,
out CylindricalTrimmedParamDomain domain)
{
domain = new CylindricalTrimmedParamDomain(0, 0, face, [], [], [], [], 1.0, []);
if (face.RuntimeFace == null || face.AxisPointMm == null || face.Axis == null || face.RadiusMm <= 1e-8)
return false;
var axisRange = ProjectBoxRange(face.Box, commonAxis);
var originProjection = Vec.Dot(commonOriginMm, commonAxis);
var axialMin = axisRange.Min - originProjection;
var axialMax = axisRange.Max - originProjection;
if (!double.IsFinite(axialMin) || !double.IsFinite(axialMax) || axialMax - axialMin <= 1e-8)
return false;
var (u, v) = PlaneBasis(commonAxis);
var faceAxisOriginAtCommonT0 = Vec.Add(
face.AxisPointMm,
Vec.Mul(commonAxis, Vec.Dot(Vec.Sub(commonOriginMm, face.AxisPointMm), commonAxis)));
domain = new CylindricalTrimmedParamDomain(
axialMin,
axialMax,
face,
commonAxis,
faceAxisOriginAtCommonT0,
u,
v,
CylindricalFaceSense(face),
ExtractCylindricalBoundarySegments(face, commonOriginMm, commonAxis, u, v));
return true;
}
static double CylindricalFaceSense(FaceEvidence face)
{
if (face.RuntimeFace == null)
return 1.0;
try
{
return face.RuntimeFace.FaceInSurfaceSense() ? -1.0 : 1.0;
}
catch
{
return 1.0;
}
}
static List<CylindricalBoundarySegment> ExtractCylindricalBoundarySegments(
FaceEvidence face,
double[] commonOriginMm,
double[] commonAxis,
double[] u,
double[] v)
{
var segments = new List<CylindricalBoundarySegment>();
if (face.RuntimeFace == null)
return segments;
try
{
foreach (var loopObject in ToObjectArray(face.RuntimeFace.GetLoops()))
{
if (loopObject is not Loop2 loop)
continue;
foreach (var edgeObject in ToObjectArray(loop.GetEdges()))
{
if (edgeObject is not Edge edge)
continue;
var curveParams = Safe(() => edge.GetCurveParams3(), null);
if (curveParams == null)
continue;
var parameterMin = curveParams.UMinValue;
var parameterMax = curveParams.UMaxValue;
if (!double.IsFinite(parameterMin) || !double.IsFinite(parameterMax))
continue;
CylindricalBoundaryPoint? previous = null;
for (var sampleIndex = 0; sampleIndex <= CylindricalBoundarySamplesPerEdge; sampleIndex++)
{
var fraction = sampleIndex / (double)CylindricalBoundarySamplesPerEdge;
var parameter = parameterMin + (parameterMax - parameterMin) * fraction;
var evaluated = ToDoubleArray(Safe<object?>(() => edge.Evaluate2(parameter, 0), null));
if (evaluated.Length < 3)
continue;
var localPointMm = new[] { evaluated[0] * 1000.0, evaluated[1] * 1000.0, evaluated[2] * 1000.0 };
var assemblyPointMm = TransformPointBySnapshot(face.AssemblyTransformSnapshot, localPointMm);
if (!TryMapPointToCylindricalCoordinates(assemblyPointMm, commonOriginMm, commonAxis, u, v, out var current))
continue;
current = current with { PointMm = assemblyPointMm };
if (previous.HasValue && Vec.Norm(Vec.Sub(current.PointMm, previous.Value.PointMm)) > 1e-7)
{
var thetaEnd = previous.Value.ThetaRadians +
NormalizeSignedAngleRadians(current.ThetaRadians - previous.Value.ThetaRadians);
segments.Add(new CylindricalBoundarySegment(
previous.Value.ThetaRadians,
thetaEnd,
previous.Value.AxialMm,
current.AxialMm));
}
previous = current;
}
}
}
}
catch
{
}
return segments;
}
static bool TryMapPointToCylindricalCoordinates(
double[] pointMm,
double[] originMm,
double[] axis,
double[] u,
double[] v,
out CylindricalBoundaryPoint point)
{
point = default;
var relative = Vec.Sub(pointMm, originMm);
var axial = Vec.Dot(relative, axis);
var radial = Vec.Sub(relative, Vec.Mul(axis, axial));
if (Vec.Norm(radial) <= 1e-8)
return false;
point = new CylindricalBoundaryPoint(
NormalizeAngleRadians(Math.Atan2(Vec.Dot(radial, v), Vec.Dot(radial, u))),
axial,
pointMm);
return true;
}
static List<AngularInterval> CylindricalAngularIntervalsAtT(CylindricalTrimmedParamDomain domain, double axialMm)
{
var fullTurn = Math.PI * 2.0;
var cuts = new List<double>(domain.BoundarySegments.Count + CylindricalFallbackAngularSectors + 2)
{
0.0,
fullTurn
};
for (var sector = 1; sector < CylindricalFallbackAngularSectors; sector++)
cuts.Add(fullTurn * sector / CylindricalFallbackAngularSectors);
foreach (var segment in domain.BoundarySegments)
{
var axialDelta = segment.AxialEndMm - segment.AxialStartMm;
if (Math.Abs(axialDelta) <= 1e-8)
continue;
var minAxial = Math.Min(segment.AxialStartMm, segment.AxialEndMm);
var maxAxial = Math.Max(segment.AxialStartMm, segment.AxialEndMm);
if (axialMm < minAxial - 1e-7 || axialMm > maxAxial + 1e-7)
continue;
var fraction = Math.Clamp((axialMm - segment.AxialStartMm) / axialDelta, 0.0, 1.0);
var theta = segment.ThetaStartRadians + (segment.ThetaEndRadians - segment.ThetaStartRadians) * fraction;
cuts.Add(NormalizeAngleRadians(theta));
}
var orderedCuts = new List<double>();
foreach (var cut in cuts.Where(double.IsFinite).OrderBy(value => value))
{
if (orderedCuts.Count == 0 || cut - orderedCuts[^1] > 1e-6)
orderedCuts.Add(cut);
}
var intervals = new List<AngularInterval>();
for (var i = 0; i + 1 < orderedCuts.Count; i++)
{
var min = orderedCuts[i];
var max = orderedCuts[i + 1];
if (max - min <= 1e-6)
continue;
if (IsPointOnCylindricalTrimmedFace(domain, axialMm, (min + max) * 0.5))
intervals.Add(new AngularInterval(min, max));
}
return MergeAngularIntervals(intervals);
}
static bool IsPointOnCylindricalTrimmedFace(CylindricalTrimmedParamDomain domain, double axialMm, double thetaRadians)
{
var radial = Vec.Add(
Vec.Mul(domain.U, Math.Cos(thetaRadians)),
Vec.Mul(domain.V, Math.Sin(thetaRadians)));
var pointMm = Vec.Add(
Vec.Add(domain.FaceAxisOriginAtCommonT0Mm, Vec.Mul(domain.CommonAxis, axialMm)),
Vec.Mul(radial, domain.Face.RadiusMm));
return DistanceToTrimmedFaceMm(domain.Face, pointMm) <= CylindricalFaceMembershipToleranceMm;
}
static double DistanceToTrimmedFaceMm(FaceEvidence face, double[] assemblyPointMm)
{
if (face.RuntimeFace == null)
return double.PositiveInfinity;
var localPointMm = InverseTransformPointBySnapshot(face.AssemblyTransformSnapshot, assemblyPointMm);
var closest = ToDoubleArray(Safe<object?>(() => face.RuntimeFace.GetClosestPointOn(
localPointMm[0] / 1000.0,
localPointMm[1] / 1000.0,
localPointMm[2] / 1000.0), null));
var best = ClosestPointDistanceMm(face, assemblyPointMm, closest);
if (best <= CylindricalFaceMembershipToleranceMm || face.AssemblyTransformSnapshot.Length < 13)
return best;
var assemblyClosest = ToDoubleArray(Safe<object?>(() => face.RuntimeFace.GetClosestPointOn(
assemblyPointMm[0] / 1000.0,
assemblyPointMm[1] / 1000.0,
assemblyPointMm[2] / 1000.0), null));
return Math.Min(best, ClosestPointDistanceMm(face, assemblyPointMm, assemblyClosest));
}
static double ClosestPointDistanceMm(FaceEvidence face, double[] assemblyPointMm, double[] closest)
{
if (closest.Length < 3)
return double.PositiveInfinity;
var rawMm = new[] { closest[0] * 1000.0, closest[1] * 1000.0, closest[2] * 1000.0 };
var transformedMm = TransformPointBySnapshot(face.AssemblyTransformSnapshot, rawMm);
return Math.Min(
Vec.Norm(Vec.Sub(assemblyPointMm, transformedMm)),
Vec.Norm(Vec.Sub(assemblyPointMm, rawMm)));
}
static List<AngularInterval> MergeAngularIntervals(List<AngularInterval> intervals)
{
if (intervals.Count == 0)
return [];
var ordered = intervals.OrderBy(interval => interval.MinRadians).ToList();
var merged = new List<AngularInterval>();
var current = ordered[0];
for (var i = 1; i < ordered.Count; i++)
{
var next = ordered[i];
if (next.MinRadians <= current.MaxRadians + 1e-6)
{
current = new AngularInterval(current.MinRadians, Math.Max(current.MaxRadians, next.MaxRadians));
continue;
}
merged.Add(current);
current = next;
}
merged.Add(current);
return merged;
}
static bool AngularIntervalsOverlap(IReadOnlyList<AngularInterval> a, IReadOnlyList<AngularInterval> b)
{
foreach (var intervalA in a)
foreach (var intervalB in b)
{
if (Math.Min(intervalA.MaxRadians, intervalB.MaxRadians) -
Math.Max(intervalA.MinRadians, intervalB.MinRadians) > 1e-6)
return true;
}
return false;
}
static double NormalizeAngleRadians(double angle)
{
var full = Math.PI * 2.0;
angle %= full;
return angle < 0 ? angle + full : angle;
}
static double NormalizeSignedAngleRadians(double angle)
{
var full = Math.PI * 2.0;
angle %= full;
if (angle > Math.PI)
angle -= full;
else if (angle < -Math.PI)
angle += full;
return angle;
}
static List<double[]> ProjectTriangle2D(FaceTriangleMm triangle, double[] u, double[] v) =>
[
[Vec.Dot(triangle.A, u), Vec.Dot(triangle.A, v)],
[Vec.Dot(triangle.B, u), Vec.Dot(triangle.B, v)],
[Vec.Dot(triangle.C, u), Vec.Dot(triangle.C, v)]
];
static (double XMin, double XMax, double YMin, double YMax) PolygonBox2D(IReadOnlyList<double[]> polygon) =>
(
polygon.Min(point => point[0]),
polygon.Max(point => point[0]),
polygon.Min(point => point[1]),
polygon.Max(point => point[1])
);
static bool BoxesOverlap2D(
(double XMin, double XMax, double YMin, double YMax) a,
(double XMin, double XMax, double YMin, double YMax) b,
double tolerance) =>
a.XMax + tolerance >= b.XMin && b.XMax + tolerance >= a.XMin &&
a.YMax + tolerance >= b.YMin && b.YMax + tolerance >= a.YMin;
static double ConvexPolygonIntersectionArea(IReadOnlyList<double[]> subject, IReadOnlyList<double[]> clip)
{
var output = subject.Select(point => point.ToArray()).ToList();
var clipSign = SignedArea2D(clip) >= 0 ? 1.0 : -1.0;
for (var i = 0; i < clip.Count && output.Count > 0; i++)
{
var edgeA = clip[i];
var edgeB = clip[(i + 1) % clip.Count];
var input = output;
output = [];
var previous = input[^1];
var previousInside = IsInsideClipEdge(previous, edgeA, edgeB, clipSign);
foreach (var current in input)
{
var currentInside = IsInsideClipEdge(current, edgeA, edgeB, clipSign);
if (currentInside != previousInside && TryLineIntersection2D(previous, current, edgeA, edgeB, out var intersection))
output.Add(intersection);
if (currentInside)
output.Add(current);
previous = current;
previousInside = currentInside;
}
}
return Math.Abs(SignedArea2D(output));
}
static double SignedArea2D(IReadOnlyList<double[]> polygon)
{
if (polygon.Count < 3)
return 0;
var twiceArea = 0.0;
for (var i = 0; i < polygon.Count; i++)
{
var a = polygon[i];
var b = polygon[(i + 1) % polygon.Count];
twiceArea += a[0] * b[1] - a[1] * b[0];
}
return twiceArea * 0.5;
}
static bool IsInsideClipEdge(double[] point, double[] edgeA, double[] edgeB, double clipSign) =>
clipSign * ((edgeB[0] - edgeA[0]) * (point[1] - edgeA[1]) -
(edgeB[1] - edgeA[1]) * (point[0] - edgeA[0])) >= -1e-9;
static bool TryLineIntersection2D(double[] a, double[] b, double[] c, double[] d, out double[] intersection)
{
var rx = b[0] - a[0];
var ry = b[1] - a[1];
var sx = d[0] - c[0];
var sy = d[1] - c[1];
var denominator = rx * sy - ry * sx;
if (Math.Abs(denominator) < 1e-12)
{
intersection = [];
return false;
}
var t = ((c[0] - a[0]) * sy - (c[1] - a[1]) * sx) / denominator;
intersection = [a[0] + t * rx, a[1] + t * ry];
return true;
}
static double MinimumTessellationDistanceMm(FaceEvidence a, FaceEvidence b)
{
var minA = MinimumSamplesToTrianglesDistanceMm(a.SamplePointsMm, b.TrianglesMm);
var minB = MinimumSamplesToTrianglesDistanceMm(b.SamplePointsMm, a.TrianglesMm);
return Math.Min(minA, minB);
}
static double MinimumSamplesToTrianglesDistanceMm(
IReadOnlyList<double[]> samples,
IReadOnlyList<FaceTriangleMm> triangles)
{
var minimumSquared = double.PositiveInfinity;
foreach (var sample in samples)
foreach (var triangle in triangles)
{
var distanceSquared = PointTriangleDistanceSquared(sample, triangle.A, triangle.B, triangle.C);
if (distanceSquared < minimumSquared)
minimumSquared = distanceSquared;
}
return double.IsFinite(minimumSquared) ? Math.Sqrt(minimumSquared) : double.PositiveInfinity;
}
static double PointTriangleDistanceSquared(double[] point, double[] a, double[] b, double[] c)
{
var ab = Vec.Sub(b, a);
var ac = Vec.Sub(c, a);
var ap = Vec.Sub(point, a);
var d1 = Vec.Dot(ab, ap);
var d2 = Vec.Dot(ac, ap);
if (d1 <= 0 && d2 <= 0)
return Vec.Dot(ap, ap);
var bp = Vec.Sub(point, b);
var d3 = Vec.Dot(ab, bp);
var d4 = Vec.Dot(ac, bp);
if (d3 >= 0 && d4 <= d3)
return Vec.Dot(bp, bp);
var vc = d1 * d4 - d3 * d2;
if (vc <= 0 && d1 >= 0 && d3 <= 0)
{
var edge = Vec.Add(a, Vec.Mul(ab, d1 / (d1 - d3)));
var delta = Vec.Sub(point, edge);
return Vec.Dot(delta, delta);
}
var cp = Vec.Sub(point, c);
var d5 = Vec.Dot(ab, cp);
var d6 = Vec.Dot(ac, cp);
if (d6 >= 0 && d5 <= d6)
return Vec.Dot(cp, cp);
var vb = d5 * d2 - d1 * d6;
if (vb <= 0 && d2 >= 0 && d6 <= 0)
{
var edge = Vec.Add(a, Vec.Mul(ac, d2 / (d2 - d6)));
var delta = Vec.Sub(point, edge);
return Vec.Dot(delta, delta);
}
var va = d3 * d6 - d5 * d4;
if (va <= 0 && d4 - d3 >= 0 && d5 - d6 >= 0)
{
var bc = Vec.Sub(c, b);
var edge = Vec.Add(b, Vec.Mul(bc, (d4 - d3) / ((d4 - d3) + (d5 - d6))));
var delta = Vec.Sub(point, edge);
return Vec.Dot(delta, delta);
}
var denominator = 1.0 / (va + vb + vc);
var closest = Vec.Add(a, Vec.Add(Vec.Mul(ab, vb * denominator), Vec.Mul(ac, vc * denominator)));
var closestDelta = Vec.Sub(point, closest);
return Vec.Dot(closestDelta, closestDelta);
}
static (double Min, double Max) ProjectBoxRange(Box box, double[] axis)
{
var values = Corners(box).Select(p => Vec.Dot(p, axis)).ToList();
return (values.Min(), values.Max());
}
static double AxisDistanceMm(double[] pointA, double[] axisA, double[] pointB)
{
var delta = Vec.Sub(pointB, pointA);
var along = Vec.Mul(axisA, Vec.Dot(delta, axisA));
return Vec.Norm(Vec.Sub(delta, along));
}
static IEnumerable<double[]> Corners(Box b)
{
foreach (double x in new[] { b.XMin, b.XMax })
foreach (double y in new[] { b.YMin, b.YMax })
foreach (double z in new[] { b.ZMin, b.ZMax })
yield return [x, y, z];
}
static string ClassifyStable(string name, string path)
{
var fileName = Path.GetFileNameWithoutExtension(path);
var s = $"{name} {fileName}".ToLowerInvariant();
if (ContainsAny(s, "\u5c0f\u81c2\u5173\u8282\u88c5\u914d2", "小臂关节装配2"))
return "unknown";
if (ContainsAny(s, "\u5c0f\u81c2\u5173\u8282\u88c5\u914d2", "小臂关节装配2"))
return "purchased_motor_reducer_assembly";
var purchasedExcluded = LooksLikeCustomAdapterPart(s);
if (!purchasedExcluded && ContainsAny(s, "\u8f74\u627f", "bearing"))
return "bearing";
if (!purchasedExcluded && ContainsAny(s, "\u7535\u673a", "\u9a6c\u8fbe", "\u4f3a\u670d", "motor", "servo"))
return "purchased_motor";
if (!purchasedExcluded && ContainsAny(s, "\u51cf\u901f\u5668", "\u51cf\u901f\u673a", "\u51cf\u901f\u7535\u673a", "reducer", "gearbox", "gear motor"))
return "purchased_reducer";
if (ContainsAny(s, "\u5957\u7b52", "\u8f74\u5957", "\u886c\u5957", "sleeve", "bushing"))
return "sleeve";
if (ContainsAny(s, "\u7aa5\u89c6\u5b54\u76d6", "inspection cover", "peephole cover"))
return "inspection_cover";
if (ContainsAny(s, "\u7aef\u76d6", "end cap", "end cover", "cover"))
return "end_cap";
if (ContainsAny(s, "\u7bb1\u4f53", "\u673a\u5ea7", "\u5b54\u5ea7", "housing", "case"))
return "housing";
if (ContainsAny(s, "\u8f74", "shaft"))
return "shaft";
return "unknown";
}
static bool IsUserPurchasedComponent(string name, string path, IReadOnlyList<string> hints)
{
if (hints.Count == 0)
return false;
var fileName = Path.GetFileNameWithoutExtension(path);
var candidates = new[]
{
NormalizeMatchText(name),
NormalizeMatchText(fileName),
NormalizeMatchText(StripSolidWorksInstanceSuffix(name))
};
return hints
.Select(NormalizeMatchText)
.Where(hint => hint.Length > 0)
.Any(hint => candidates.Any(candidate => candidate.Equals(hint, StringComparison.OrdinalIgnoreCase)));
}
static bool IsExternalInterfaceTargetRelation(
string componentA,
string componentPathA,
string componentB,
string componentPathB,
IReadOnlyList<string> roots)
{
var aIsTarget = IsUserPurchasedComponentOrChild(componentA, componentPathA, roots);
var bIsTarget = IsUserPurchasedComponentOrChild(componentB, componentPathB, roots);
return aIsTarget ^ bIsTarget;
}
static bool IsUserPurchasedComponentOrChild(string name, string path, IReadOnlyList<string> roots)
{
if (IsUserPurchasedComponent(name, path, roots))
return true;
var candidates = new[]
{
NormalizeHierarchyMatchText(name),
NormalizeHierarchyMatchText(StripSolidWorksInstanceSuffix(name)),
NormalizeHierarchyMatchText(Path.GetFileNameWithoutExtension(path))
};
foreach (var root in roots.Select(NormalizeHierarchyMatchText).Where(root => root.Length > 0))
{
if (candidates.Any(candidate =>
candidate.Equals(root, StringComparison.OrdinalIgnoreCase) ||
candidate.StartsWith(root + "/", StringComparison.OrdinalIgnoreCase) ||
candidate.StartsWith(root + "-", StringComparison.OrdinalIgnoreCase)))
{
return true;
}
}
return false;
}
static string StripSolidWorksInstanceSuffix(string name)
{
var value = name.Trim();
var slash = value.LastIndexOfAny(['/', '\\']);
var prefix = slash >= 0 ? value[..(slash + 1)] : "";
var leaf = slash >= 0 ? value[(slash + 1)..] : value;
var angleIndex = leaf.LastIndexOf('<');
if (angleIndex > 0 && leaf.EndsWith(">", StringComparison.Ordinal))
leaf = leaf[..angleIndex];
else
{
var dash = leaf.LastIndexOf('-');
if (dash > 0 && dash + 1 < leaf.Length && leaf[(dash + 1)..].All(char.IsDigit))
leaf = leaf[..dash];
}
return prefix + leaf;
}
static string NormalizeMatchText(string value) =>
new(value
.Trim()
.ToLowerInvariant()
.Where(ch => !char.IsWhiteSpace(ch) && ch is not '-' and not '_' and not '/' and not '\\')
.ToArray());
static string NormalizeHierarchyMatchText(string value) =>
new((value ?? "")
.Trim()
.Replace('\\', '/')
.ToLowerInvariant()
.Where(ch => !char.IsWhiteSpace(ch))
.ToArray());
static bool ContainsAny(string value, params string[] tokens)
{
return tokens.Any(t => value.Contains(t, StringComparison.OrdinalIgnoreCase));
}
static bool LooksLikeCustomAdapterPart(string value) =>
ContainsAny(
value,
"\u8fde\u63a5\u4ef6", "\u8fde\u63a5\u81c2", "\u8fde\u63a5\u6cd5\u5170", "\u6cd5\u5170",
"\u652f\u67b6", "\u5b89\u88c5\u5ea7", "\u5b89\u88c5\u677f", "\u56fa\u5b9a\u677f",
"\u5916\u58f3", "\u58f3\u4f53", "\u7aef\u76d6", "\u673a\u5ea7", "\u5ea7", "\u81c2", "\u677f",
"connector", "adapter", "flange", "bracket", "mount", "mounting", "housing", "shell", "cover", "arm", "plate");
#pragma warning disable CS8321
static string Classify(string name, string path)
{
var s = $"{name} {path}".ToLowerInvariant();
if (s.Contains("套筒") || s.Contains("轴套") || s.Contains("sleeve") || s.Contains("bushing"))
return "sleeve";
if (s.Contains("箱") || s.Contains("座") || s.Contains("housing") || s.Contains("case"))
return "housing";
if (s.Contains("轴") || s.Contains("shaft"))
return "shaft";
if (s.Contains("轴承") || s.Contains("bearing"))
return "bearing";
return "unknown";
}
#pragma warning restore CS8321
static string BuildMarkdown(SectionBrepReport report)
{
var sb = new StringBuilder();
sb.AppendLine("# Section B-rep Report");
sb.AppendLine($"- Ok: {report.Ok}");
sb.AppendLine($"- Path: `{report.Path}`");
sb.AppendLine($"- Document kind: `{report.DocumentKind}`");
sb.AppendLine($"- Message: {report.Message}");
if (!string.IsNullOrWhiteSpace(report.Error))
sb.AppendLine($"- Error: `{report.Error.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries).FirstOrDefault()}`");
if (!string.IsNullOrWhiteSpace(report.OutputDir))
sb.AppendLine($"- Output dir: `{report.OutputDir}`");
sb.AppendLine($"- Axis group: `{report.AxisGroupId}`");
sb.AppendLine($"- Canonical edges: {report.CanonicalSection.Edges.Count}");
sb.AppendLine($"- Assembly components: {report.AssemblyComponents.Count}");
sb.AppendLine($"- Component evidence packages: {report.ComponentEvidencePackages.Count}");
sb.AppendLine($"- Part image plan items: {report.PartImagePlan.Count}");
sb.AppendLine($"- Assembly faces: {report.AssemblyFaces.Count}");
sb.AppendLine($"- Assembly face contacts: {report.AssemblyFaceContacts.Count}");
sb.AppendLine($"- Assembly mates: {report.AssemblyMates.Count}");
sb.AppendLine($"- Mechanical features: {report.FeatureGraph.Features.Count}");
if (report.ImageExport != null)
{
sb.AppendLine($"- Image export: {report.ImageExport.SuccessCount}/{report.ImageExport.Views.Count} views -> `{report.ImageExport.OutputDir}`");
}
if (report.SectionImagePlan.Requests.Count > 0)
{
sb.AppendLine($"- Auto section image plan: {report.SectionImagePlan.Requests.Count}/{report.SectionImagePlan.MaxRequests} requests");
}
sb.AppendLine();
sb.AppendLine("## Plane");
sb.AppendLine($"- Origin: [{string.Join(", ", report.SectionPlane.OriginMm)}]");
sb.AppendLine($"- Axis: [{string.Join(", ", report.SectionPlane.AxisMm)}]");
sb.AppendLine($"- Plane normal: [{string.Join(", ", report.SectionPlane.PlaneNormalMm)}]");
sb.AppendLine();
sb.AppendLine("## Assembly Frame");
sb.AppendLine($"- Shaft axis: [{string.Join(", ", report.AssemblyFrame.ShaftAxisMm)}]");
sb.AppendLine($"- Vertical axis: [{string.Join(", ", report.AssemblyFrame.VerticalAxisMm)}]");
sb.AppendLine($"- Inference: {report.AssemblyFrame.Inference}");
sb.AppendLine();
if (report.AssemblyComponents.Count > 0)
{
sb.AppendLine("## Assembly Components");
foreach (var component in report.AssemblyComponents.Take(120))
{
sb.AppendLine($"- {component.Id}: {component.DisplayName}, category={component.Category}, review_scope={component.ReviewScope}, bodies={component.BodyCount}, faces={component.FaceCount}, file=`{component.FileName}`");
}
sb.AppendLine();
}
if (report.ComponentEvidencePackages.Count > 0)
{
sb.AppendLine("## Component Evidence Packages");
foreach (var package in report.ComponentEvidencePackages.Take(120))
{
sb.AppendLine($"- {package.ComponentId}: {package.DisplayName}, review_scope={package.ReviewScope}, brep_source={package.BrepSource}, image_source={package.ImageSource}, faces={package.FaceRefs.Count}, features={package.FeatureIds.Count}, contacts={package.ContactIds.Count}, mates={package.MateIds.Count}, part_image_plan={package.PartImagePlanId}");
}
sb.AppendLine();
}
if (report.PartImagePlan.Count > 0)
{
sb.AppendLine("## Part Image Plan");
foreach (var item in report.PartImagePlan.Take(120))
{
sb.AppendLine($"- {item.Id}: {item.DisplayName}, file=`{item.ComponentFileName}`, brep_source={item.BrepSource}, image_source={item.ImageSource}, do_not_reextract_brep={item.DoNotReextractBrep}, views={string.Join(",", item.SuggestedViews)}");
}
sb.AppendLine();
}
if (report.ComponentContextImagePlan.Count > 0)
{
sb.AppendLine("## Component Context Image Plan");
foreach (var item in report.ComponentContextImagePlan.Take(120))
{
var shown = string.Join(",", item.ShowComponents.Take(8).Select(c => c.ComponentId));
var internalMembers = string.Join(",", item.InternalMembers.Take(8).Select(c => c.ComponentId));
sb.AppendLine($"- {item.Id}: target={item.TargetComponentId} `{item.TargetDisplayName}`, group={item.GroupId}, strategy={item.Strategy}, display={item.DisplayMode}, views={string.Join(",", item.SuggestedViews)}, shown=[{shown}], internal_members=[{internalMembers}]");
}
sb.AppendLine();
}
foreach (var c in report.Components)
{
sb.AppendLine($"## {c.ComponentName}");
sb.AppendLine($"- Category: {c.Category}");
sb.AppendLine($"- Entities: {c.SectionEntities.Count}");
foreach (var e in c.SectionEntities.Take(12))
sb.AppendLine($" - {e.EntityKind}#{e.EntityIndex} via {e.Provenance}");
sb.AppendLine();
}
if (report.AssemblyFaceContacts.Count > 0)
{
sb.AppendLine("## Assembly Face Contacts");
foreach (var contact in report.AssemblyFaceContacts.Take(40))
{
sb.AppendLine($"- {contact.MateRole}: {contact.ComponentDisplayNameA} face#{contact.FaceA} ({contact.FaceRoleA}/{contact.OrientationRoleA}/{contact.SurfaceProcessRoleA}) <-> {contact.ComponentDisplayNameB} face#{contact.FaceB} ({contact.FaceRoleB}/{contact.OrientationRoleB}/{contact.SurfaceProcessRoleB}), confidence={contact.Confidence}, gap={contact.GapMm}");
}
sb.AppendLine();
}
if (report.AssemblyMates.Count > 0)
{
sb.AppendLine("## Assembly Mates");
foreach (var mate in report.AssemblyMates.Take(60))
{
sb.AppendLine($"- {mate.Id}: {mate.MateTypeName}/{mate.AlignmentName}, {mate.ComponentDisplayNameA} <-> {mate.ComponentDisplayNameB}, name=`{mate.MateName}`");
}
sb.AppendLine();
}
if (report.FeatureGraph.Features.Count > 0)
{
sb.AppendLine("## Mechanical Feature Graph");
foreach (var feature in report.FeatureGraph.Features.Take(40))
{
var functions = string.Join(",", feature.PossibleFunctions.Take(3));
var faces = string.Join(",", feature.FaceRefs.Take(4));
var owner = string.IsNullOrWhiteSpace(feature.ComponentDisplayName) ? feature.ComponentName : feature.ComponentDisplayName;
sb.AppendLine($"- {feature.Id}: type={feature.Type}, component={owner}, faces=[{faces}], possible={functions}, confidence={feature.Confidence}");
}
sb.AppendLine();
}
if (!string.IsNullOrWhiteSpace(report.SemanticFusionContract.Schema))
{
sb.AppendLine("## Semantic Fusion Contract");
sb.AppendLine($"- Schema: `{report.SemanticFusionContract.Schema}`");
sb.AppendLine($"- Feature types: {string.Join(", ", report.SemanticFusionContract.FeatureTypes)}");
sb.AppendLine("- Pipeline:");
foreach (var step in report.SemanticFusionContract.Pipeline)
sb.AppendLine($" - {step}");
sb.AppendLine("- Semantic types:");
foreach (var semanticType in report.SemanticFusionContract.SemanticTypes)
sb.AppendLine($" - {semanticType.Name}: {semanticType.Purpose}");
sb.AppendLine();
}
if (report.ImageExport != null)
{
sb.AppendLine("## Model Images");
foreach (var view in report.ImageExport.Views)
{
sb.AppendLine($"- {view.ViewName}: ok={view.Ok}, file=`{view.OutputPath}`, errors={view.SaveErrors}, warnings={view.SaveWarnings}, message={view.Message}");
}
sb.AppendLine();
}
if (report.SectionImagePlan.Requests.Count > 0)
{
sb.AppendLine("## Auto Section Image Plan");
foreach (var request in report.SectionImagePlan.Requests)
{
sb.AppendLine($"- {request.Rule}: plane={request.PlaneKey}, offset_mm={request.OffsetMm}, target={request.Target}, reason={request.Reason}");
}
sb.AppendLine();
}
return sb.ToString();
}
static object? GetComProperty(object obj, string name)
{
return obj.GetType().InvokeMember(name, System.Reflection.BindingFlags.GetProperty, null, obj, null);
}
static object[] ToObjectArray(object? value)
{
if (value is object[] objects)
return objects;
if (value is Array array)
return array.Cast<object>().ToArray();
return [];
}
static double[] ToDoubleArray(object? value)
{
if (value is double[] d)
return d;
if (value is float[] f)
return f.Select(item => (double)item).ToArray();
if (value is object[] o)
return o.Select(Convert.ToDouble).ToArray();
if (value is Array array)
return array.Cast<object>().Select(Convert.ToDouble).ToArray();
return [];
}
static double[] GetComponentTransformSnapshot(
Component2 component,
IDictionary<string, double[]> cache)
{
var name = Safe(() => component.Name2, "");
if (!string.IsNullOrWhiteSpace(name) && cache.TryGetValue(name, out var cached))
return cached;
var snapshot = Safe(() =>
{
var transform = component.Transform2 as MathTransform;
return transform == null ? [] : ToDoubleArray(transform.ArrayData);
}, []);
if (!string.IsNullOrWhiteSpace(name))
cache[name] = snapshot;
return snapshot;
}
static double[] TransformPointBySnapshot(double[] transform, double[] pointMm)
{
if (transform.Length < 13 || pointMm.Length < 3)
return pointMm;
var scale = transform.Length > 12 && Math.Abs(transform[12]) > 1e-12 ? transform[12] : 1.0;
return
[
scale * (pointMm[0] * transform[0] + pointMm[1] * transform[3] + pointMm[2] * transform[6]) + transform[9] * 1000.0,
scale * (pointMm[0] * transform[1] + pointMm[1] * transform[4] + pointMm[2] * transform[7]) + transform[10] * 1000.0,
scale * (pointMm[0] * transform[2] + pointMm[1] * transform[5] + pointMm[2] * transform[8]) + transform[11] * 1000.0
];
}
static double[] InverseTransformPointBySnapshot(double[] transform, double[] pointMm)
{
if (transform.Length < 13 || pointMm.Length < 3)
return pointMm;
var scale = Math.Abs(transform[12]) > 1e-12 ? transform[12] : 1.0;
var x = (pointMm[0] - transform[9] * 1000.0) / scale;
var y = (pointMm[1] - transform[10] * 1000.0) / scale;
var z = (pointMm[2] - transform[11] * 1000.0) / scale;
return
[
x * transform[0] + y * transform[1] + z * transform[2],
x * transform[3] + y * transform[4] + z * transform[5],
x * transform[6] + y * transform[7] + z * transform[8]
];
}
static double[] TransformVectorBySnapshot(double[] transform, double[] vector)
{
if (transform.Length < 9 || vector.Length < 3)
return vector;
var scale = transform.Length > 12 && Math.Abs(transform[12]) > 1e-12 ? transform[12] : 1.0;
return
[
scale * (vector[0] * transform[0] + vector[1] * transform[3] + vector[2] * transform[6]),
scale * (vector[0] * transform[1] + vector[1] * transform[4] + vector[2] * transform[7]),
scale * (vector[0] * transform[2] + vector[1] * transform[5] + vector[2] * transform[8])
];
}
static double[] TransformBBoxBySnapshot(double[] transform, double[] bboxMm)
{
if (transform.Length < 13)
return bboxMm;
var corners = Corners(Box.FromArray(bboxMm)).Select(point => TransformPointBySnapshot(transform, point)).ToList();
return
[
corners.Min(point => point[0]), corners.Min(point => point[1]), corners.Min(point => point[2]),
corners.Max(point => point[0]), corners.Max(point => point[1]), corners.Max(point => point[2])
];
}
static double[] TransformPoint(dynamic mathUtility, Component2 component, double[] pointMm)
{
dynamic p = mathUtility.CreatePoint(new[] { pointMm[0] / 1000.0, pointMm[1] / 1000.0, pointMm[2] / 1000.0 });
object transform = component.Transform2;
dynamic t = p.MultiplyTransform(transform);
var data = ToDoubleArray(t.ArrayData);
return data.Length >= 3 ? [data[0] * 1000.0, data[1] * 1000.0, data[2] * 1000.0] : pointMm;
}
static double[] TransformBBox(dynamic mathUtility, Component2 component, double[] bboxMm)
{
var corners = Corners(Box.FromArray(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 T Safe<T>(Func<T> func, T fallback)
{
try { return func(); }
catch { return fallback; }
}
static IEnumerable<Feature> WalkFeatures(Feature? feature)
{
var visited = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var item in WalkFeaturesRecursive(feature, visited))
yield return item;
}
static IEnumerable<Feature> WalkFeaturesRecursive(Feature? feature, HashSet<string> visited)
{
for (var current = feature; current != null; current = Safe(() => current.GetNextFeature() as Feature, null))
{
var key = ComObjectIdentityKey(current, $"{Safe(() => current.GetTypeName2(), "")}|{Safe(() => current.Name, "")}");
if (!visited.Add(key))
continue;
yield return current;
for (var sub = Safe(() => current.GetFirstSubFeature() as Feature, null); sub != null; sub = Safe(() => sub.GetNextSubFeature() as Feature, null))
{
foreach (var nested in WalkFeaturesRecursive(sub, visited))
yield return nested;
}
}
}
static string ComObjectIdentityKey(object value, string fallback)
{
try
{
var ptr = Marshal.GetIUnknownForObject(value);
try
{
if (ptr != IntPtr.Zero)
return ptr.ToString();
}
finally
{
if (ptr != IntPtr.Zero)
Marshal.Release(ptr);
}
}
catch { }
return fallback;
}
static string MateTypeName(int mateType)
{
try { return ((swMateType_e)mateType).ToString(); }
catch { return mateType.ToString(CultureInfo.InvariantCulture); }
}
static string MateAlignmentName(int alignment)
{
try { return ((swMateAlign_e)alignment).ToString(); }
catch { return alignment.ToString(CultureInfo.InvariantCulture); }
}
sealed class ExtractorCommand
{
public string Path { get; set; } = "";
public double[] Axis { get; set; } = [0.0, 1.0, 0.0];
public double[]? UpAxis { get; set; }
public string? OutputDir { get; set; }
public bool ExportImages { get; set; }
public string? ImageDir { get; set; }
public List<string> ImageViews { get; set; } = [];
public string? FaceHighlightPlanPath { get; set; }
public string? ImageOnlyReportPath { get; set; }
public bool ExportSectionImages { get; set; }
public List<string> SectionImageViews { get; set; } = [];
public bool AutoSectionImages { get; set; }
public double SectionOffsetMm { get; set; }
public bool SkipSectionBrep { get; set; }
public List<string> PurchasedComponentHints { get; set; } = [];
public List<string> ExternalInterfaceRoots { get; set; } = [];
}
sealed class FaceHighlightExportPlan
{
[JsonPropertyName("schema")]
public string Schema { get; set; } = "";
[JsonPropertyName("report_file_name")]
public string ReportFileName { get; set; } = "";
[JsonPropertyName("groups")]
public List<FaceHighlightExportGroup> Groups { get; set; } = [];
}
sealed class FaceHighlightExportGroup
{
[JsonPropertyName("group_id")]
public string GroupId { get; set; } = "";
[JsonPropertyName("directory")]
public string Directory { get; set; } = "";
[JsonPropertyName("components")]
public List<FaceHighlightExportComponent> Components { get; set; } = [];
}
sealed class FaceHighlightExportComponent
{
[JsonPropertyName("component_id")]
public string ComponentId { get; set; } = "";
[JsonPropertyName("instance_name")]
public string InstanceName { get; set; } = "";
[JsonPropertyName("display_name")]
public string DisplayName { get; set; } = "";
[JsonPropertyName("component_path")]
public string ComponentPath { get; set; } = "";
[JsonPropertyName("faces")]
public List<FaceHighlightExportFace> Faces { get; set; } = [];
}
sealed class FaceHighlightExportFace
{
[JsonPropertyName("plan_id")]
public string PlanId { get; set; } = "";
[JsonPropertyName("face_ref")]
public string FaceRef { get; set; } = "";
[JsonPropertyName("face_index")]
public int FaceIndex { get; set; }
[JsonPropertyName("face_kind")]
public string FaceKind { get; set; } = "";
[JsonPropertyName("feature_id")]
public string FeatureId { get; set; } = "";
[JsonPropertyName("views")]
public List<string> Views { get; set; } = [];
[JsonPropertyName("preferred_direction_mm")]
public double[] PreferredDirectionMm { get; set; } = [];
[JsonPropertyName("output_relative_directory")]
public string OutputRelativeDirectory { get; set; } = "";
[JsonPropertyName("output_file_stem")]
public string OutputFileStem { get; set; } = "";
}
sealed class FaceHighlightPlanRunReport
{
public string Schema { get; set; } = "";
public string GenerationMode { get; set; } = "";
public string PlanPath { get; set; } = "";
public string OutputDir { get; set; } = "";
public string ReportPath { get; set; } = "";
public bool Success { get; set; }
public string Message { get; set; } = "";
public DateTimeOffset StartedAt { get; set; }
public DateTimeOffset? CompletedAt { get; set; }
public List<FaceHighlightGroupRunReport> Groups { get; set; } = [];
}
sealed class FaceHighlightGroupRunReport
{
public string GroupId { get; set; } = "";
public string Directory { get; set; } = "";
public int RequestedFaceCount { get; set; }
public int ExportedFaceCount { get; set; }
public List<FaceHighlightComponentRunReport> Components { get; set; } = [];
}
sealed class FaceHighlightComponentRunReport
{
public string ComponentId { get; set; } = "";
public string InstanceName { get; set; } = "";
public string DisplayName { get; set; } = "";
public string ComponentPath { get; set; } = "";
public int RequestedFaceCount { get; set; }
public int ExportedFaceCount { get; set; }
public List<ModelImageViewExport> Views { get; set; } = [];
}
sealed class ExtractionRunStatus
{
public string InputPath { get; set; } = "";
public string OutputDir { get; set; } = "";
public double[] Axis { get; set; } = [];
public DateTimeOffset StartedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
public DateTimeOffset? CompletedAt { get; set; }
public bool Success { get; set; }
public string CurrentStage { get; set; } = "";
public string ReportPath { get; set; } = "";
public string Error { get; set; } = "";
public List<ExtractionStageLog> Stages { get; set; } = [];
}
sealed class ExtractionStageLog
{
public string Stage { get; set; } = "";
public string Message { get; set; } = "";
public DateTimeOffset Timestamp { get; set; }
}
sealed class SectionBrepReport
{
public bool Ok { get; set; }
public string Path { get; set; } = "";
public string DocumentKind { get; set; } = "";
public string OutputDir { get; set; } = "";
public DateTimeOffset StartedAt { get; set; }
public DateTimeOffset? CompletedAt { get; set; }
public string Message { get; set; } = "";
public string Error { get; set; } = "";
public List<ExtractionStageLog> StageLogs { get; set; } = [];
public string AxisGroupId { get; set; } = "";
public AssemblyFrameEvidence AssemblyFrame { get; set; } = new();
public SectionPlaneEvidence SectionPlane { get; set; } = new();
public CanonicalSectionBrep CanonicalSection { get; set; } = new();
public Canonical2DStructureGraph Canonical2D { get; set; } = new();
public Canonical2DSketchGraph SketchGraph { get; set; } = new();
public ModelImageExportReport? ImageExport { get; set; }
public AutoSectionImagePlan SectionImagePlan { get; set; } = new();
public MechanicalFeatureGraph FeatureGraph { get; set; } = new();
public SemanticFusionContract SemanticFusionContract { get; set; } = new();
public List<AssemblyComponentSummary> AssemblyComponents { get; set; } = [];
public List<ComponentEvidencePackage> ComponentEvidencePackages { get; set; } = [];
public List<string> ExternalInterfaceRoots { get; set; } = [];
public List<PartImagePlanItem> PartImagePlan { get; set; } = [];
public List<ComponentContextImagePlanItem> ComponentContextImagePlan { get; set; } = [];
public List<AssemblyFaceEvidence> AssemblyFaces { get; set; } = [];
public List<AssemblyFaceContactEvidence> AssemblyFaceContacts { get; set; } = [];
public List<AssemblyMateRelation> AssemblyMates { get; set; } = [];
public List<SectionBodyEvidence> Components { get; set; } = [];
}
sealed class AutoSectionImagePlan
{
public string Schema { get; set; } = "auto_section_image_plan_v1";
public int MaxRequests { get; set; } = 6;
public List<AutoSectionImageRequest> Requests { get; set; } = [];
}
sealed class AutoSectionImageRequest
{
public string Request { get; set; } = "";
public string Rule { get; set; } = "";
public string Target { get; set; } = "";
public string Reason { get; set; } = "";
public string PlaneKey { get; set; } = "";
public double OffsetMm { get; set; }
public double[] OriginMm { get; set; } = [];
public double[] PreferredNormalMm { get; set; } = [];
public double[] PreferredContainedAxisMm { get; set; } = [];
public List<string> SourceRefs { get; set; } = [];
}
sealed class MechanicalFeatureGraph
{
public string Schema { get; set; } = "mechanical_feature_graph_v1";
public List<MechanicalFeature> Features { get; set; } = [];
public List<MechanicalFeatureRelation> Relations { get; set; } = [];
public List<string> FusionGuidance { get; set; } = [];
}
sealed class MechanicalFeature
{
public string Id { get; set; } = "";
public string Type { get; set; } = "";
public string ComponentName { get; set; } = "";
public string ComponentDisplayName { get; set; } = "";
public string ComponentPath { get; set; } = "";
public string ComponentFileName { get; set; } = "";
public List<string> FaceRefs { get; set; } = [];
public double Confidence { get; set; }
public string Source { get; set; } = "";
public List<string> PossibleFunctions { get; set; } = [];
public List<string> VisualObservationNeeds { get; set; } = [];
public List<string> RequiredBrepChecks { get; set; } = [];
public Dictionary<string, object?> Geometry { get; set; } = [];
}
sealed class MechanicalFeatureRelation
{
public string Type { get; set; } = "";
public string A { get; set; } = "";
public string B { get; set; } = "";
public double Confidence { get; set; }
public string Source { get; set; } = "";
public Dictionary<string, object?> Attributes { get; set; } = [];
}
sealed class HoleFeatureGroup
{
public List<AssemblyFaceEvidence> Members { get; set; } = [];
public string PatternType { get; set; } = "";
public string GroupingBasis { get; set; } = "";
public double[] PatternNormal { get; set; } = [];
public double? PatternPlaneTmm { get; set; }
public double[] PatternCenterMm { get; set; } = [];
public double PatternRadiusMm { get; set; }
public double SpacingMm { get; set; }
}
sealed class SemanticFusionContract
{
public string Schema { get; set; } = "mechanical_semantic_fusion_contract_v1";
public List<string> Pipeline { get; set; } = [];
public List<string> FeatureTypes { get; set; } = [];
public List<SemanticTypeSpec> SemanticTypes { get; set; } = [];
public List<RetrievalViewSpec> RetrievalViews { get; set; } = [];
public Dictionary<string, object?> GlobalMechanicalSemanticGraphSchema { get; set; } = [];
public Dictionary<string, object?> AssertionSchema { get; set; } = [];
public Dictionary<string, object?> LocalSemanticUnitSchema { get; set; } = [];
public Dictionary<string, object?> CoverageAuditSchema { get; set; } = [];
public Dictionary<string, object?> BrepFactBundleSchema { get; set; } = [];
public Dictionary<string, object?> LocalSubgraphSchema { get; set; } = [];
public List<string> EvidenceStatusValues { get; set; } = [];
public List<string> ConfidencePolicy { get; set; } = [];
public List<string> DedupPolicy { get; set; } = [];
public List<string> RetrievalPolicy { get; set; } = [];
}
sealed class SemanticTypeSpec
{
public string Name { get; set; } = "";
public string AppliesTo { get; set; } = "";
public string Purpose { get; set; } = "";
public List<string> TypicalTargets { get; set; } = [];
}
sealed class RetrievalViewSpec
{
public string Id { get; set; } = "";
public string Name { get; set; } = "";
public string Focus { get; set; } = "";
}
sealed class Canonical2DStructureGraph
{
public Canonical2DSource Source { get; set; } = new();
public List<Canonical2DEdge> Edges { get; set; } = [];
public List<Canonical2DRegion> Regions { get; set; } = [];
public List<Canonical2DRelation> Relations { get; set; } = [];
public List<Canonical2DAnnotation> Annotations { get; set; } = [];
}
sealed class Canonical2DSource
{
public string Kind { get; set; } = "";
public string CoordinateSystem { get; set; } = "";
public Dictionary<string, object?> Metadata { get; set; } = [];
}
sealed class Canonical2DEdge
{
public string Id { get; set; } = "";
public string Type { get; set; } = "";
public double[] Start { get; set; } = [];
public double[] End { get; set; } = [];
public string Owner { get; set; } = "";
public string Role { get; set; } = "";
public string SourceEntity { get; set; } = "";
public Dictionary<string, object?> Attributes { get; set; } = [];
}
sealed class Canonical2DRegion
{
public string Id { get; set; } = "";
public string Owner { get; set; } = "";
public string Role { get; set; } = "";
public List<string> EdgeRefs { get; set; } = [];
}
sealed class Canonical2DRelation
{
public string Type { get; set; } = "";
public string A { get; set; } = "";
public string B { get; set; } = "";
public Dictionary<string, object?> Attributes { get; set; } = [];
}
sealed class Canonical2DAnnotation
{
public string Id { get; set; } = "";
public string Type { get; set; } = "";
public double[] Start { get; set; } = [];
public double[] End { get; set; } = [];
public Dictionary<string, object?> Attributes { get; set; } = [];
}
sealed class Canonical2DSketchGraph
{
public string Schema { get; set; } = "canonical_2d_sketch_graph_v1";
public Canonical2DSketchSource Source { get; set; } = new();
public List<SketchVertex> Vertices { get; set; } = [];
public List<SketchDrawableEdge> Edges { get; set; } = [];
public List<SketchLoop> Loops { get; set; } = [];
public List<SketchPath> Paths { get; set; } = [];
public List<SketchRegion> Regions { get; set; } = [];
public List<SketchRelation> Relations { get; set; } = [];
public List<SketchDimension> Dimensions { get; set; } = [];
}
sealed class Canonical2DSketchSource
{
public string Kind { get; set; } = "";
public string CoordinateSystem { get; set; } = "";
public string Units { get; set; } = "";
public Dictionary<string, object?> Metadata { get; set; } = [];
}
sealed class SketchVertex
{
public string Id { get; set; } = "";
public double[] Point { get; set; } = [];
public double[] NormalizedPoint { get; set; } = [];
}
sealed class SketchDrawableEdge
{
public string Id { get; set; } = "";
public List<string> SourceEdgeIds { get; set; } = [];
public string Type { get; set; } = "";
public string StartVertex { get; set; } = "";
public string EndVertex { get; set; } = "";
public double[] Start { get; set; } = [];
public double[] End { get; set; } = [];
public double[] NormalizedStart { get; set; } = [];
public double[] NormalizedEnd { get; set; } = [];
public string Owner { get; set; } = "";
public string Role { get; set; } = "";
public double Length { get; set; }
public Dictionary<string, object?> Attributes { get; set; } = [];
}
sealed class SketchLoop
{
public string Id { get; set; } = "";
public List<string> EdgeRefs { get; set; } = [];
public List<string> SourceEdgeRefs { get; set; } = [];
public bool Closed { get; set; }
public string Role { get; set; } = "";
}
sealed class SketchPath
{
public string Id { get; set; } = "";
public List<string> EdgeRefs { get; set; } = [];
public List<string> SourceEdgeRefs { get; set; } = [];
public List<string> VertexRefs { get; set; } = [];
public List<double[]> Polyline { get; set; } = [];
public bool Closed { get; set; }
public string Role { get; set; } = "";
}
sealed class SketchRegion
{
public string Id { get; set; } = "";
public string Owner { get; set; } = "";
public string Role { get; set; } = "";
public List<string> SourceEdgeRefs { get; set; } = [];
public List<string> LoopRefs { get; set; } = [];
}
sealed class SketchRelation
{
public string Type { get; set; } = "";
public string A { get; set; } = "";
public string B { get; set; } = "";
public Dictionary<string, object?> Attributes { get; set; } = [];
}
sealed class SketchDimension
{
public string Id { get; set; } = "";
public string Kind { get; set; } = "";
public List<string> TargetRefs { get; set; } = [];
public double Value { get; set; }
public string Units { get; set; } = "";
public string Role { get; set; } = "";
}
sealed class MergedSketchEdge
{
public string Key { get; set; } = "";
public string Type { get; set; } = "";
public double[] Start { get; set; } = [];
public double[] End { get; set; } = [];
public string Owner { get; set; } = "";
public string Role { get; set; } = "";
public List<string> SourceIds { get; set; } = [];
public Dictionary<string, object?> Attributes { get; set; } = [];
}
sealed class CanonicalSectionBrep
{
public string Schema { get; set; } = "canonical_section_brep_v0";
public string SourceKind { get; set; } = "";
public string CoordinateSystem { get; set; } = "";
public List<CanonicalSectionEdge> Edges { get; set; } = [];
public List<CanonicalSectionAnnotation> Annotations { get; set; } = [];
public Dictionary<string, object?> Metadata { get; set; } = [];
}
sealed class CanonicalSectionEdge
{
public string Id { get; set; } = "";
public string Kind { get; set; } = "";
public double[] Start { get; set; } = [];
public double[] End { get; set; } = [];
public double Length { get; set; }
public Dictionary<string, object?> Attributes { get; set; } = [];
}
sealed class CanonicalSectionAnnotation
{
public string Id { get; set; } = "";
public string Kind { get; set; } = "";
public double[] Start { get; set; } = [];
public double[] End { get; set; } = [];
public Dictionary<string, object?> Attributes { get; set; } = [];
}
sealed class SectionPlaneEvidence
{
public double[] OriginMm { get; set; } = [];
public double[] AxisMm { get; set; } = [];
public double[] PlaneNormalMm { get; set; } = [];
public double[] UAxisMm { get; set; } = [];
public double[] VAxisMm { get; set; } = [];
}
sealed class SectionBodyEvidence
{
public string ComponentName { get; set; } = "";
public string Category { get; set; } = "";
public List<SectionEntityEvidence> SectionEntities { get; set; } = [];
}
sealed class SectionEntityEvidence
{
public string ComponentName { get; set; } = "";
public string EntityKind { get; set; } = "";
public int EntityIndex { get; set; }
public string Provenance { get; set; } = "";
public Dictionary<string, object?> Geometry { get; set; } = [];
}
sealed class AssemblyComponentSummary
{
public string Id { get; set; } = "";
public string InstanceName { get; set; } = "";
public string DisplayName { get; set; } = "";
public string FileName { get; set; } = "";
public string Path { get; set; } = "";
public string Category { get; set; } = "";
public string ReviewScope { get; set; } = "";
public int BodyCount { get; set; }
public int FaceCount { get; set; }
public int CylinderFaceCount { get; set; }
public double[] BBoxMm { get; set; } = [];
}
sealed class ComponentEvidencePackage
{
public string ComponentId { get; set; } = "";
public string InstanceName { get; set; } = "";
public string DisplayName { get; set; } = "";
public string FileName { get; set; } = "";
public string Path { get; set; } = "";
public string Category { get; set; } = "";
public string ReviewScope { get; set; } = "";
public string BrepSource { get; set; } = "";
public string ImageSource { get; set; } = "";
public string PartImagePlanId { get; set; } = "";
public string AssemblyContextImagePlanId { get; set; } = "";
public List<string> FaceRefs { get; set; } = [];
public List<string> FeatureIds { get; set; } = [];
public List<string> ContactIds { get; set; } = [];
public List<string> MateIds { get; set; } = [];
public int BodyCount { get; set; }
public int FaceCount { get; set; }
public int CylinderFaceCount { get; set; }
public double[] BBoxMm { get; set; } = [];
public List<string> Rules { get; set; } = [];
}
sealed class PartImagePlanItem
{
public string Id { get; set; } = "";
public string ComponentId { get; set; } = "";
public string InstanceName { get; set; } = "";
public string DisplayName { get; set; } = "";
public string ComponentFileName { get; set; } = "";
public string ComponentPath { get; set; } = "";
public string BrepSource { get; set; } = "";
public string ImageSource { get; set; } = "";
public string AssemblyContextImagePlanId { get; set; } = "";
public string AssemblyContextImageSource { get; set; } = "";
public bool DoNotReextractBrep { get; set; }
public List<string> SuggestedViews { get; set; } = [];
public List<string> SuggestedAssemblyContextViews { get; set; } = [];
public string HighlightInstruction { get; set; } = "";
public List<string> Purpose { get; set; } = [];
}
sealed class ComponentContextImagePlanItem
{
public string Id { get; set; } = "";
public string GroupId { get; set; } = "";
public string TargetComponentId { get; set; } = "";
public string TargetInstanceName { get; set; } = "";
public string TargetDisplayName { get; set; } = "";
public string TargetComponentPath { get; set; } = "";
public string Strategy { get; set; } = "";
public string DisplayMode { get; set; } = "";
public string OcclusionMethod { get; set; } = "";
public bool RestoreRequired { get; set; }
public bool SaveModel { get; set; }
public List<string> SuggestedViews { get; set; } = [];
public List<ComponentContextMember> ShowComponents { get; set; } = [];
public List<ComponentContextMember> InternalMembers { get; set; } = [];
public List<ComponentContextMember> BoundaryComponents { get; set; } = [];
public InternalOcclusionAnalysis TargetOcclusion { get; set; } = new();
public List<InternalOcclusionAnalysis> MemberOcclusionAnalyses { get; set; } = [];
public string HiddenPolicy { get; set; } = "";
public List<string> Purpose { get; set; } = [];
}
sealed class ComponentContextMember
{
public string ComponentId { get; set; } = "";
public string InstanceName { get; set; } = "";
public string DisplayName { get; set; } = "";
public string ComponentPath { get; set; } = "";
public string Role { get; set; } = "";
}
sealed class InternalOcclusionAnalysis
{
public string ComponentId { get; set; } = "";
public string InstanceName { get; set; } = "";
public string DisplayName { get; set; } = "";
public string Method { get; set; } = "";
public bool IsInternal { get; set; }
public int OccludedDirectionCount { get; set; }
public int OpenDirectionCount { get; set; }
public bool EarlyStop { get; set; }
public string Decision { get; set; } = "";
public List<InternalOcclusionDirectionAnalysis> Directions { get; set; } = [];
}
sealed class InternalOcclusionDirectionAnalysis
{
public string Direction { get; set; } = "";
public bool Occluded { get; set; }
public double OcclusionRatio { get; set; }
public List<OcclusionBlocker> FirstHitComponents { get; set; } = [];
}
sealed class OcclusionBlocker
{
public string ComponentId { get; set; } = "";
public string InstanceName { get; set; } = "";
public string DisplayName { get; set; } = "";
public string ComponentPath { get; set; } = "";
}
sealed class ComponentEvidence
{
public string Name { get; set; } = "";
public string Path { get; set; } = "";
public string Category { get; set; } = "";
public MathTransform? Transform { get; set; }
public List<Body2> Bodies { get; set; } = [];
public List<FaceEvidence> Faces { get; set; } = [];
public List<FaceEvidence> Cylinders { get; set; } = [];
public Box? BoundingBox { get; set; }
}
sealed class FaceEvidence
{
public int FaceIndex { get; set; }
public string Kind { get; set; } = "other";
public double AreaMm2 { get; set; }
public Box Box { get; set; }
public double[] CenterMm { get; set; } = [];
public double[]? Normal { get; set; }
public double[]? RootPointMm { get; set; }
public double[]? AxisPointMm { get; set; }
public double[]? Axis { get; set; }
public double RadiusMm { get; set; }
public List<double[]> SamplePointsMm { get; set; } = [];
public List<FaceTriangleMm> TrianglesMm { get; set; } = [];
public bool TessellationAttempted { get; set; }
[JsonIgnore]
public Face2? RuntimeFace { get; set; }
[JsonIgnore]
public double[] AssemblyTransformSnapshot { get; set; } = [];
}
sealed class AxisGroupEvidence
{
public string GroupId { get; set; } = "";
public double[] AxisPointMm { get; set; } = [];
public double[] Axis { get; set; } = [];
public int CylinderCount { get; set; }
public int ComponentCount { get; set; }
public double RadiusMinMm { get; set; }
public double RadiusMaxMm { get; set; }
public List<AxisMemberEvidence> Members { get; set; } = [];
}
sealed class AxisMemberEvidence
{
public string ComponentName { get; set; } = "";
public string ComponentCategory { get; set; } = "";
public int FaceIndex { get; set; }
public double RadiusMm { get; set; }
public double AreaMm2 { get; set; }
}
sealed class AssemblyMateRelation
{
public string Id { get; set; } = "";
public string MateName { get; set; } = "";
public int MateType { get; set; }
public string MateTypeName { get; set; } = "";
public int Alignment { get; set; }
public string AlignmentName { get; set; } = "";
public string ComponentA { get; set; } = "";
public string ComponentDisplayNameA { get; set; } = "";
public string ComponentPathA { get; set; } = "";
public string ComponentFileNameA { get; set; } = "";
public string ComponentB { get; set; } = "";
public string ComponentDisplayNameB { get; set; } = "";
public string ComponentPathB { get; set; } = "";
public string ComponentFileNameB { get; set; } = "";
public int EntityCount { get; set; }
public string Evidence { get; set; } = "";
}
sealed class AssemblyFaceContactEvidence
{
public string Id { get; set; } = "";
public string ContactKind { get; set; } = "";
public string ComponentA { get; set; } = "";
public string ComponentDisplayNameA { get; set; } = "";
public string ComponentPathA { get; set; } = "";
public string ComponentFileNameA { get; set; } = "";
public string ComponentCategoryA { get; set; } = "";
public int FaceA { get; set; }
public string FaceKindA { get; set; } = "";
public string ComponentB { get; set; } = "";
public string ComponentDisplayNameB { get; set; } = "";
public string ComponentPathB { get; set; } = "";
public string ComponentFileNameB { get; set; } = "";
public string ComponentCategoryB { get; set; } = "";
public int FaceB { get; set; }
public string FaceKindB { get; set; } = "";
public string MateRole { get; set; } = "";
public string FaceRoleA { get; set; } = "";
public string FaceRoleB { get; set; } = "";
public string OrientationRoleA { get; set; } = "";
public string OrientationRoleB { get; set; } = "";
public string SurfaceProcessRoleA { get; set; } = "";
public string SurfaceProcessRoleB { get; set; } = "";
public string ProcessConfidenceA { get; set; } = "";
public string ProcessConfidenceB { get; set; } = "";
public string ProcessBasisA { get; set; } = "";
public string ProcessBasisB { get; set; } = "";
public string MachiningScopeA { get; set; } = "";
public string MachiningScopeB { get; set; } = "";
public double GapMm { get; set; }
public double NormalDot { get; set; }
public double RadiusAmm { get; set; }
public double RadiusBmm { get; set; }
public double AxisDistanceMm { get; set; }
public double AxialOverlapMm { get; set; }
public double OverlapMeasureMm2 { get; set; }
public bool TrimmedPatchVerified { get; set; }
public string VerificationStatus { get; set; } = "";
public string VerificationMethod { get; set; } = "";
public int VerificationSampleHits { get; set; }
public double VerificationMinDistanceMm { get; set; }
public double Confidence { get; set; }
public string Evidence { get; set; } = "";
public double[] CenterA { get; set; } = [];
public double[] CenterB { get; set; } = [];
public double[] NormalA { get; set; } = [];
public double[] NormalB { get; set; } = [];
public double[] AxisA { get; set; } = [];
public double[] AxisB { get; set; } = [];
public double[] BBoxA { get; set; } = [];
public double[] BBoxB { get; set; } = [];
[JsonIgnore]
public Face2? RuntimeFaceA { get; set; }
[JsonIgnore]
public Face2? RuntimeFaceB { get; set; }
}
sealed class AssemblyFaceEvidence
{
public string ComponentName { get; set; } = "";
public string ComponentDisplayName { get; set; } = "";
public string ComponentPath { get; set; } = "";
public string ComponentFileName { get; set; } = "";
public string ComponentCategory { get; set; } = "";
public int FaceIndex { get; set; }
public string FaceKind { get; set; } = "";
public double AreaMm2 { get; set; }
public double[] CenterMm { get; set; } = [];
public double[] BBoxMm { get; set; } = [];
public double[] Normal { get; set; } = [];
public double[] RootPointMm { get; set; } = [];
public double[] AxisPointMm { get; set; } = [];
public double[] Axis { get; set; } = [];
public double RadiusMm { get; set; }
public double DiameterMm { get; set; }
public string OrientationRole { get; set; } = "";
public string FunctionalRole { get; set; } = "";
public string SurfaceProcessRole { get; set; } = "";
public string ProcessConfidence { get; set; } = "";
public string ProcessBasis { get; set; } = "";
public string MachiningScope { get; set; } = "";
}
sealed class AssemblyFrameEvidence
{
public double[] ShaftAxisMm { get; set; } = [];
public double[] VerticalAxisMm { get; set; } = [];
public string Inference { get; set; } = "";
}
sealed class FaceProcessAnnotation
{
public string SurfaceProcessRole { get; set; } = "";
public string ProcessConfidence { get; set; } = "";
public string ProcessBasis { get; set; } = "";
public string MachiningScope { get; set; } = "";
}
readonly record struct Box(double XMin, double YMin, double ZMin, double XMax, double YMax, double ZMax)
{
public static Box FromArray(double[] b) => new(b[0], b[1], b[2], b[3], b[4], b[5]);
public double[] ToArrayRounded() => [Math.Round(XMin, 3), Math.Round(YMin, 3), Math.Round(ZMin, 3), Math.Round(XMax, 3), Math.Round(YMax, 3), Math.Round(ZMax, 3)];
}
readonly record struct OcclusionDirectionSpec(string Name, int Axis, double Sign, int UAxis, int VAxis);
readonly record struct ProjectionRect(double UMin, double UMax, double VMin, double VMax)
{
public double AreaMm2 => Math.Max(0.0, UMax - UMin) * Math.Max(0.0, VMax - VMin);
}
sealed record CylindricalTrimmedParamDomain(
double AxialMinMm,
double AxialMaxMm,
FaceEvidence Face,
double[] CommonAxis,
double[] FaceAxisOriginAtCommonT0Mm,
double[] U,
double[] V,
double NormalSense,
List<CylindricalBoundarySegment> BoundarySegments);
readonly record struct CylindricalBoundaryPoint(double ThetaRadians, double AxialMm, double[] PointMm);
readonly record struct CylindricalBoundarySegment(
double ThetaStartRadians,
double ThetaEndRadians,
double AxialStartMm,
double AxialEndMm);
readonly record struct AngularInterval(double MinRadians, double MaxRadians);
readonly record struct IndexedFace(int FaceIndex, Face2 Face, Component2? OwnerComponent);
sealed record FaceTriangleMm(double[] A, double[] B, double[] C);
sealed record FaceTessellation(List<double[]> SamplePointsMm, List<FaceTriangleMm> TrianglesMm);
sealed record TrimmedPatchVerification(
bool Verified,
string Status,
string Method,
double OverlapMeasure,
int SampleHits,
double MinDistanceMm,
double RepresentativeNormalDot = double.NaN)
{
public static TrimmedPatchVerification Unverified(string method) =>
new(false, "unverified_trimmed_domain", method, 0, 0, double.PositiveInfinity);
public static TrimmedPatchVerification Rejected(string method) =>
new(false, "extension_only_rejected", method, 0, 0, double.PositiveInfinity);
}
static class Vec
{
public static double[] Normalize(double[] v)
{
var n = Norm(v);
return n < 1e-9 ? [0, 0, 0] : v.Select(x => x / n).ToArray();
}
public static double Norm(double[] v) => Math.Sqrt(v.Sum(x => x * x));
public static double Dot(double[] a, double[] b) => a.Zip(b, (x, y) => x * y).Sum();
public static double[] Add(double[] a, double[] b) => a.Zip(b, (x, y) => x + y).ToArray();
public static double[] Sub(double[] a, double[] b) => a.Zip(b, (x, y) => x - y).ToArray();
public static double[] Mul(double[] a, double s) => a.Select(x => x * s).ToArray();
public static double[] Cross(double[] a, double[] b) =>
[
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0]
];
public static double[] Round(double[]? v) => v == null ? [] : v.Select(x => Math.Round(x, 3)).ToArray();
}