Files
mech-ai/backend-csharp/Agent4.Api/DrawingAssemblyDiagnostics.cs
T
2026-07-17 17:45:56 +08:00

6675 lines
295 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Diagnostics;
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
static class DrawingAssemblyDiagnosticApi
{
public static IServiceCollection AddDrawingAssemblyDiagnostics(this IServiceCollection services)
{
services.AddSingleton<DrawingAssemblyDiagnosticService>();
return services;
}
public static WebApplication MapDrawingAssemblyDiagnosticApi(this WebApplication app)
{
var group = app.MapGroup("/api/drawing-assembly-diagnostics");
var partGroup = app.MapGroup("/api/drawing-part-diagnostics");
group.MapGet("/usage", (DrawingAssemblyDiagnosticService service) =>
{
return Results.Ok(service.GetUsage("drawing-assembly"));
});
group.MapPost("/analyze", async (DrawingAssemblyAnalysisRequest request, DrawingAssemblyDiagnosticService service) =>
{
try
{
return Results.Ok(await service.AnalyzeAsync(request));
}
catch (Exception ex)
{
return Results.Problem($"二维装配图后续分析失败:{ex.Message}", statusCode: 500);
}
});
group.MapPost("/run-full", async (DrawingAssemblyFullRunRequest request, DrawingAssemblyDiagnosticService service) =>
{
try
{
return Results.Ok(await service.RunFullAsync(request));
}
catch (Exception ex)
{
return Results.Problem($"Drawing assembly full diagnostic failed: {ex.Message}", statusCode: 500);
}
});
group.MapPost("/build-tolerance-context", (DrawingToleranceContextRequest request, DrawingAssemblyDiagnosticService service) =>
{
try
{
return Results.Ok(service.BuildToleranceContext(request));
}
catch (Exception ex)
{
return Results.Problem($"构建公差判定上下文失败:{ex.Message}", statusCode: 500);
}
});
group.MapPost("/build-interface-drawing-context", (DrawingToleranceContextRequest request, DrawingAssemblyDiagnosticService service) =>
{
try
{
return Results.Ok(service.BuildInterfaceDrawingContext(request));
}
catch (Exception ex)
{
return Results.Problem($"构建三维接口与二维标注上下文失败:{ex.Message}", statusCode: 500);
}
});
group.MapPost("/assess-tolerances", async (DrawingToleranceAssessmentRunRequest request, DrawingAssemblyDiagnosticService service) =>
{
try
{
return Results.Ok(await service.AssessTolerancesAsync(request));
}
catch (Exception ex)
{
return Results.Problem($"二维装配图公差查错失败:{ex.Message}", statusCode: 500);
}
});
group.MapGet("/latest", (DrawingAssemblyDiagnosticService service) =>
{
try
{
return Results.Ok(service.ReadLatest());
}
catch (Exception ex)
{
return Results.Problem($"读取二维装配图分析结果失败:{ex.Message}", statusCode: 500);
}
});
group.MapGet("/image", (string path, DrawingAssemblyDiagnosticService service) =>
{
var image = service.ResolveEvidenceImage(path);
return Results.File(image.FilePath, image.ContentType);
});
partGroup.MapPost("/run-full", async (DrawingPartFormatRunRequest request, DrawingAssemblyDiagnosticService service) =>
{
try
{
return Results.Ok(await service.RunPartFormatAsync(request));
}
catch (Exception ex)
{
return Results.Problem($"Drawing part format diagnostic failed: {ex.Message}", statusCode: 500);
}
});
partGroup.MapGet("/usage", (DrawingAssemblyDiagnosticService service) =>
{
return Results.Ok(service.GetUsage("drawing-part"));
});
partGroup.MapGet("/latest", (DrawingAssemblyDiagnosticService service) =>
{
try
{
return Results.Ok(service.ReadPartLatest());
}
catch (Exception ex)
{
return Results.Problem($"Read drawing part diagnostic result failed: {ex.Message}", statusCode: 500);
}
});
partGroup.MapGet("/image", (string path, DrawingAssemblyDiagnosticService service) =>
{
var image = service.ResolveEvidenceImage(path);
return Results.File(image.FilePath, image.ContentType);
});
return app;
}
}
sealed class DrawingAssemblyDiagnosticService
{
static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower
};
readonly AppPaths _paths;
readonly IConfiguration _configuration;
public DrawingAssemblyDiagnosticService(AppPaths paths, IConfiguration configuration)
{
_paths = paths;
_configuration = configuration;
}
static string ReadTextIfExists(string path) =>
File.Exists(path) ? File.ReadAllText(path, Encoding.UTF8) : "";
public DrawingDiagnosticUsage GetUsage(string target)
{
var normalized = target.Equals("drawing-part", StringComparison.OrdinalIgnoreCase)
? "drawing-part"
: "drawing-assembly";
return new DrawingDiagnosticUsage
{
Target = normalized,
FlowBoundaryDocPath = _paths.DiagnosticFlowBoundariesDoc,
FlowBoundaryMarkdown = ReadTextIfExists(_paths.DiagnosticFlowBoundariesDoc),
DrawingDiagnosticReadmePath = _paths.DrawingDiagnosticReadmeDoc,
DrawingDiagnosticReadmeMarkdown = ReadTextIfExists(_paths.DrawingDiagnosticReadmeDoc),
AssemblyToleranceFlowPath = _paths.DrawingAssemblyToleranceFlowDoc,
AssemblyToleranceFlowMarkdown = normalized == "drawing-assembly"
? ReadTextIfExists(_paths.DrawingAssemblyToleranceFlowDoc)
: "",
SupportedStages = normalized == "drawing-part"
?
[
"dwg_extraction",
"programmatic_format_rule_check",
"format_issue_evidence_images",
"final_format_report"
]
:
[
"student_dwg_extraction",
"standard_view_and_ownership_pipeline",
"view_alignment",
"drawing_ownership_context",
"tolerance_to_dwg_geometry_binding",
"drawing_boundary_to_3d_brep_interface_binding",
"multi_view_tolerance_aggregation",
"deterministic_tolerance_diagnostic",
"optional_ai_tolerance_assessment",
"ai_annotation_and_representation_judgement"
],
BackendRole = normalized == "drawing-part"
? "二维零件图当前只做程序化格式查错;线型/线宽等格式错误不交给 AI。"
: "二维装配图负责 DWG 抽取、标准视图/归属证据复用、公差到三维 B-rep 接口绑定、多视图汇总和 AI 最终判定。"
};
}
public async Task<DrawingAssemblyAnalysisResult> RunFullAsync(DrawingAssemblyFullRunRequest request)
{
var studentDwg = RequireExistingFile(request.StudentDwgPath, ".dwg", nameof(request.StudentDwgPath));
var modelPath = RequireExistingFile(request.ModelPath, ".sldasm", nameof(request.ModelPath));
var outputDir = PrepareOutputDir(request.OutputDir);
var pipelineDir = Path.Combine(outputDir, "pipeline");
var mechanicalContextDir = ResolveMechanicalContextDir(request.MechanicalContextDir, modelPath, pipelineDir);
Directory.CreateDirectory(pipelineDir);
var commands = new List<DrawingAssemblyCommandResult>();
void SaveStage(string stage, object data) => WriteJson(Path.Combine(pipelineDir, "stage-" + stage + ".json"), data);
var studentInitialExtraction = Path.Combine(pipelineDir, "01_student_main_view_extraction");
commands.Add(await RunDotnetProjectAsync(
DrawingToolProject("AutoCadDwgExtractor"),
[
"--drawing-path", studentDwg,
"--output-dir", studentInitialExtraction,
"--visible", request.Visible.ToString().ToLowerInvariant(),
"--close-after-extract", "true",
"--max-entities", request.MaxEntities.ToString()
],
"01_extract_student_main_view"));
RequireFile(Path.Combine(studentInitialExtraction, "main-view-signature.json"), "student main-view signature");
var candidateDir = Path.Combine(pipelineDir, "02_solidworks_candidates");
commands.Add(await RunDotnetProjectAsync(
DrawingToolProject("SolidWorksViewCandidateGenerator"),
[
"--model", modelPath,
"--use-active-drawing",
"--export-candidate-dwgs-sequential",
"--output-dir", candidateDir
],
"02_export_solidworks_candidate_views"));
var candidateViewsJson = RequireFile(Path.Combine(candidateDir, "candidate-views.json"), "candidate views");
var candidateExtractionRoot = Path.Combine(candidateDir, "autocad-extractions-relative");
commands.Add(await RunPowerShellScriptAsync(
Path.Combine(_paths.Agent4Root, "tools", "drawing-diagnostics", "SolidWorksViewCandidateGenerator", "ExtractCandidateDwgsSequential.ps1"),
[
"-CandidateViewsJson", candidateViewsJson,
"-OutputRoot", candidateExtractionRoot,
request.Visible ? "-Visible" : ""
],
"03_extract_candidate_dwgs"));
commands.Add(await RunPowerShellScriptAsync(
Path.Combine(_paths.Agent4Root, "tools", "drawing-diagnostics", "SolidWorksViewCandidateGenerator", "RankExtractedCandidateDwgs.ps1"),
[
"-TargetSignaturePath", Path.Combine(studentInitialExtraction, "main-view-signature.json"),
"-CandidateExtractionRoot", candidateExtractionRoot,
"-CandidateViewsJson", candidateViewsJson
],
"04_rank_candidate_views"));
var rankingJson = RequireFile(Path.Combine(candidateExtractionRoot, "candidate-ranking-relative.json"), "candidate ranking");
var placedMainViewDir = Path.Combine(pipelineDir, "05_placed_main_view");
commands.Add(await RunDotnetProjectAsync(
DrawingToolProject("SolidWorksViewCandidateGenerator"),
[
"--model", modelPath,
"--use-active-drawing",
"--place-main-view",
"--ranking-json", rankingJson,
"--output-dir", placedMainViewDir
],
"05_place_main_view"));
var swBeforeAlignDir = Path.Combine(pipelineDir, "06_sw_current_dwg_before_origin_align");
var swBeforeAlignDwg = Path.Combine(swBeforeAlignDir, "solidworks-current-before-origin-align.dwg");
commands.Add(await RunDotnetProjectAsync(
DrawingToolProject("SolidWorksViewCandidateGenerator"),
[
"--model", modelPath,
"--use-active-drawing",
"--export-active-drawing-dwg",
"--dwg", swBeforeAlignDwg,
"--output-dir", swBeforeAlignDir
],
"06_export_current_solidworks_dwg"));
RequireFile(swBeforeAlignDwg, "fresh SolidWorks DWG before origin alignment");
var alignedStudentDir = Path.Combine(pipelineDir, "07_aligned_student_dwg");
var alignedStudentDwg = Path.Combine(alignedStudentDir, Path.GetFileNameWithoutExtension(studentDwg) + ".aligned-to-solidworks-frame.dwg");
commands.Add(await RunDotnetProjectAsync(
DrawingToolProject("DwgSheetFrameAligner"),
[
"--reference-dwg", swBeforeAlignDwg,
"--student-dwg", studentDwg,
"--output-dwg", alignedStudentDwg,
"--visible", request.Visible.ToString().ToLowerInvariant(),
"--close-after-align", "true"
],
"07_align_student_dwg_to_sw_frame"));
RequireFile(alignedStudentDwg, "aligned student DWG");
var alignedStudentExtraction = Path.Combine(alignedStudentDir, "extraction");
commands.Add(await RunDotnetProjectAsync(
DrawingToolProject("AutoCadDwgExtractor"),
[
"--drawing-path", alignedStudentDwg,
"--output-dir", alignedStudentExtraction,
"--visible", request.Visible.ToString().ToLowerInvariant(),
"--close-after-extract", "true",
"--max-entities", request.MaxEntities.ToString()
],
"08_extract_aligned_student_dwg"));
var swBeforeAlignExtraction = Path.Combine(swBeforeAlignDir, "extraction");
commands.Add(await RunDotnetProjectAsync(
DrawingToolProject("AutoCadDwgExtractor"),
[
"--drawing-path", swBeforeAlignDwg,
"--output-dir", swBeforeAlignExtraction,
"--main-view-seed-mode", "solidworks-multiview-inner-frame",
"--visible", request.Visible.ToString().ToLowerInvariant(),
"--close-after-extract", "true",
"--max-entities", request.MaxEntities.ToString()
],
"09_extract_solidworks_current_dwg"));
var placedMainViewName = FirstNonEmpty(
ReadPlacedMainViewOrientationName(Path.Combine(placedMainViewDir, "candidate-result.json")),
"agent4_main_view");
var alignOriginDir = Path.Combine(pipelineDir, "10_aligned_solidworks_main_view_origin");
var alignArgs = new List<string>
{
"--model", modelPath,
"--use-active-drawing",
"--align-main-view-origin",
"--student-signature", Path.Combine(alignedStudentExtraction, "main-view-signature.json"),
"--reference-signature", Path.Combine(swBeforeAlignExtraction, "main-view-signature.json"),
"--output-dir", alignOriginDir
};
if (!string.IsNullOrWhiteSpace(placedMainViewName))
{
alignArgs.Add("--main-view-orientation-name");
alignArgs.Add(placedMainViewName);
}
commands.Add(await RunDotnetProjectAsync(
DrawingToolProject("SolidWorksViewCandidateGenerator"),
alignArgs,
"10_align_solidworks_main_view_origin"));
var swAfterAlignDir = Path.Combine(pipelineDir, "11_sw_current_dwg_after_origin_align");
var swAfterAlignDwg = Path.Combine(swAfterAlignDir, "solidworks-current-after-origin-align.dwg");
commands.Add(await RunDotnetProjectAsync(
DrawingToolProject("SolidWorksViewCandidateGenerator"),
[
"--model", modelPath,
"--use-active-drawing",
"--export-active-drawing-dwg",
"--dwg", swAfterAlignDwg,
"--output-dir", swAfterAlignDir
],
"11_export_origin_aligned_solidworks_dwg"));
RequireFile(swAfterAlignDwg, "fresh SolidWorks DWG after origin alignment");
var swAfterAlignExtraction = Path.Combine(swAfterAlignDir, "extraction");
commands.Add(await RunDotnetProjectAsync(
DrawingToolProject("AutoCadDwgExtractor"),
[
"--drawing-path", swAfterAlignDwg,
"--output-dir", swAfterAlignExtraction,
"--main-view-seed-mode", "solidworks-multiview-inner-frame",
"--main-view-reference-selection", Path.Combine(alignedStudentExtraction, "main-view-selection.json"),
"--visible", request.Visible.ToString().ToLowerInvariant(),
"--close-after-extract", "true",
"--max-entities", request.MaxEntities.ToString()
],
"12_extract_origin_aligned_solidworks_dwg"));
var studentRegions = ReadStudentViewRegions(alignedStudentExtraction);
SaveStage("13_student_view_regions", new
{
source = alignedStudentExtraction,
regions = studentRegions
});
var projectedRegions = studentRegions
.Where(region => region.Kind is "bottom_view_candidate" or "right_of_main_view_candidate")
.ToList();
var projectedIndex = 0;
var mainRegion = studentRegions.First(region => region.Kind == "main_view");
foreach (var region in projectedRegions)
{
projectedIndex++;
var direction = region.Kind == "right_of_main_view_candidate" ? "right" : "down";
var projectedViewName = direction == "right" ? "agent4_right_projected_view" : "agent4_bottom_projected_view";
var placement = InitialProjectedViewPlacement(mainRegion.Bounds, region.Bounds, direction);
commands.Add(await RunDotnetProjectAsync(
DrawingToolProject("SolidWorksViewCandidateGenerator"),
[
"--model", modelPath,
"--use-active-drawing",
"--place-projected-view",
"--main-view-orientation-name", placedMainViewName,
"--projected-view-name", projectedViewName,
"--projected-view-direction", direction,
"--projected-view-x-m", (placement.X / 1000.0).ToString(System.Globalization.CultureInfo.InvariantCulture),
"--projected-view-y-m", (placement.Y / 1000.0).ToString(System.Globalization.CultureInfo.InvariantCulture),
"--output-dir", Path.Combine(pipelineDir, $"14_place_projected_view_{projectedIndex:00}_{direction}")
],
$"14_place_projected_view_{projectedIndex:00}_{direction}"));
}
var swWithProjectedViewsDir = Path.Combine(pipelineDir, "15_sw_current_dwg_with_projected_views");
var swWithProjectedViewsDwg = Path.Combine(swWithProjectedViewsDir, "solidworks-current-with-projected-views.dwg");
commands.Add(await RunDotnetProjectAsync(
DrawingToolProject("SolidWorksViewCandidateGenerator"),
[
"--model", modelPath,
"--use-active-drawing",
"--export-active-drawing-dwg",
"--dwg", swWithProjectedViewsDwg,
"--output-dir", swWithProjectedViewsDir
],
"15_export_solidworks_dwg_with_projected_views"));
RequireFile(swWithProjectedViewsDwg, "SolidWorks DWG with projected views");
var swWithProjectedViewsExtraction = Path.Combine(swWithProjectedViewsDir, "extraction");
commands.Add(await RunDotnetProjectAsync(
DrawingToolProject("AutoCadDwgExtractor"),
[
"--drawing-path", swWithProjectedViewsDwg,
"--output-dir", swWithProjectedViewsExtraction,
"--main-view-seed-mode", "solidworks-multiview-inner-frame",
"--main-view-reference-selection", Path.Combine(alignedStudentExtraction, "main-view-selection.json"),
"--visible", request.Visible.ToString().ToLowerInvariant(),
"--close-after-extract", "true",
"--max-entities", request.MaxEntities.ToString()
],
"16_extract_solidworks_dwg_with_projected_views"));
foreach (var region in projectedRegions)
{
var direction = region.Kind == "right_of_main_view_candidate" ? "right" : "down";
var projectedViewName = direction == "right" ? "agent4_right_projected_view" : "agent4_bottom_projected_view";
commands.Add(await RunDotnetProjectAsync(
DrawingToolProject("SolidWorksViewCandidateGenerator"),
[
"--model", modelPath,
"--use-active-drawing",
"--align-orthographic-views-to-regions",
"--projected-view-name", projectedViewName,
"--projected-view-direction", direction,
"--student-view-regions", Path.Combine(alignedStudentExtraction, "view-regions.json"),
"--reference-view-regions", Path.Combine(swWithProjectedViewsExtraction, "view-regions.json"),
"--alignment-axis", direction == "down" ? "y" : "x",
"--output-dir", Path.Combine(pipelineDir, $"17_align_projected_view_{direction}")
],
$"17_align_projected_view_{direction}"));
}
if (HasSectionCutSymbols(Path.Combine(alignedStudentExtraction, "section-cut-symbols.json")))
{
commands.Add(await RunDotnetProjectAsync(
DrawingToolProject("SolidWorksViewCandidateGenerator"),
[
"--model", modelPath,
"--use-active-drawing",
"--place-section-views",
"--main-view-orientation-name", placedMainViewName,
"--section-cut-symbols", Path.Combine(alignedStudentExtraction, "section-cut-symbols.json"),
"--output-dir", Path.Combine(pipelineDir, "18_place_section_views")
],
"18_place_section_views"));
}
else
{
SaveStage("18_place_section_views_skipped", new
{
reason = "no_section_cut_symbols",
source = Path.Combine(alignedStudentExtraction, "section-cut-symbols.json")
});
}
var swWithOtherViewsDir = Path.Combine(pipelineDir, "19_sw_current_dwg_with_other_views");
var swWithOtherViewsDwg = Path.Combine(swWithOtherViewsDir, "solidworks-current-with-other-views.dwg");
commands.Add(await RunDotnetProjectAsync(
DrawingToolProject("SolidWorksViewCandidateGenerator"),
[
"--model", modelPath,
"--use-active-drawing",
"--export-active-drawing-dwg",
"--dwg", swWithOtherViewsDwg,
"--output-dir", swWithOtherViewsDir
],
"19_export_solidworks_dwg_with_other_views"));
RequireFile(swWithOtherViewsDwg, "SolidWorks DWG with other views");
var swWithOtherViewsExtraction = Path.Combine(swWithOtherViewsDir, "extraction");
commands.Add(await RunDotnetProjectAsync(
DrawingToolProject("AutoCadDwgExtractor"),
[
"--drawing-path", swWithOtherViewsDwg,
"--output-dir", swWithOtherViewsExtraction,
"--main-view-seed-mode", "solidworks-multiview-inner-frame",
"--main-view-reference-selection", Path.Combine(alignedStudentExtraction, "main-view-selection.json"),
"--visible", request.Visible.ToString().ToLowerInvariant(),
"--close-after-extract", "true",
"--max-entities", request.MaxEntities.ToString()
],
"20_extract_solidworks_dwg_with_other_views"));
if (HasSectionViewRegions(Path.Combine(alignedStudentExtraction, "view-regions.json")) &&
HasSectionViewRegions(Path.Combine(swWithOtherViewsExtraction, "view-regions.json")))
{
commands.Add(await RunDotnetProjectAsync(
DrawingToolProject("SolidWorksViewCandidateGenerator"),
[
"--model", modelPath,
"--use-active-drawing",
"--align-section-views-to-regions",
"--student-view-regions", Path.Combine(alignedStudentExtraction, "view-regions.json"),
"--reference-view-regions", Path.Combine(swWithOtherViewsExtraction, "view-regions.json"),
"--output-dir", Path.Combine(pipelineDir, "21_align_section_views")
],
"21_align_section_views"));
}
else
{
SaveStage("21_align_section_views_skipped", new
{
reason = "student_or_solidworks_section_regions_missing",
student_view_regions = Path.Combine(alignedStudentExtraction, "view-regions.json"),
reference_view_regions = Path.Combine(swWithOtherViewsExtraction, "view-regions.json")
});
}
var finalSwDrawingDir = Path.Combine(pipelineDir, "22_final_solidworks_drawing_dwg");
var finalSwDrawingDwg = Path.Combine(finalSwDrawingDir, "solidworks-final-all-views.dwg");
commands.Add(await RunDotnetProjectAsync(
DrawingToolProject("SolidWorksViewCandidateGenerator"),
[
"--model", modelPath,
"--use-active-drawing",
"--export-active-drawing-dwg",
"--dwg", finalSwDrawingDwg,
"--output-dir", finalSwDrawingDir
],
"19_export_final_solidworks_all_views_dwg"));
RequireFile(finalSwDrawingDwg, "final SolidWorks all-view DWG");
var finalSwExtraction = Path.Combine(finalSwDrawingDir, "extraction");
commands.Add(await RunDotnetProjectAsync(
DrawingToolProject("AutoCadDwgExtractor"),
[
"--drawing-path", finalSwDrawingDwg,
"--output-dir", finalSwExtraction,
"--main-view-seed-mode", "solidworks-multiview-inner-frame",
"--main-view-reference-selection", Path.Combine(alignedStudentExtraction, "main-view-selection.json"),
"--visible", request.Visible.ToString().ToLowerInvariant(),
"--close-after-extract", "true",
"--max-entities", request.MaxEntities.ToString()
],
"20_extract_final_solidworks_all_views_dwg"));
var allViewBounds = UnionBounds(studentRegions.Select(region => region.Bounds));
var pngRegions = studentRegions
.Append(new DrawingViewRegion("all_views", "all_views", "all_views", allViewBounds))
.ToList();
SaveStage("21_png_export_regions", new
{
source = alignedStudentExtraction,
regions = pngRegions
});
var studentPngDir = Path.Combine(pipelineDir, "21_student_view_pngs");
var studentPngArgs = new List<string>
{
"--drawing-path", alignedStudentDwg,
"--output-dir", studentPngDir,
"--include-annotations-in-bounds"
};
studentPngArgs.AddRange(BuildViewBoundsArgs(pngRegions));
commands.Add(await RunDotnetProjectAsync(
DrawingToolProject("DwgViewPngExporter"),
studentPngArgs,
"21_export_student_view_pngs"));
var studentViewPng = RequireFile(Path.Combine(studentPngDir, "all_views.png"), "student all-views PNG");
var ownershipDir = Path.Combine(pipelineDir, "22_ownership");
var ownershipJsonl = Path.Combine(ownershipDir, "ownership-grid.jsonl");
commands.Add(await RunDotnetProjectAsync(
DrawingToolProject("SolidWorksHoverProbe"),
[
"--hover-grid",
"--views-only",
"--step-mm", request.OwnershipStepMm.ToString(System.Globalization.CultureInfo.InvariantCulture),
"--hover-delay-ms", request.HoverDelayMs.ToString(),
"--zoom-fit-delay-ms", request.ZoomFitDelayMs.ToString(),
"--output", ownershipJsonl
],
"22_probe_solidworks_ownership_grid"));
RequireFile(ownershipJsonl, "ownership JSONL");
var ownershipDwg = Path.Combine(ownershipDir, "solidworks-ownership-colored.dwg");
var ownershipDrawArgs = new List<string>
{
"--input-jsonl", ownershipJsonl,
"--source-dwg", finalSwDrawingDwg,
"--output-dwg", ownershipDwg,
"--report", Path.Combine(ownershipDir, "ownership-report.json"),
"--legend", Path.Combine(ownershipDir, "ownership-legend.txt"),
"--color-entities"
};
foreach (var region in studentRegions)
{
ownershipDrawArgs.Add("--view-bounds-mm");
ownershipDrawArgs.Add(region.Bounds.ToCliString());
}
commands.Add(await RunDotnetProjectAsync(
DrawingToolProject("DwgOwnershipContourDrawer"),
ownershipDrawArgs,
"23_draw_ownership_colored_dwg"));
RequireFile(ownershipDwg, "ownership colored DWG");
var ownershipPngDir = Path.Combine(pipelineDir, "24_ownership_view_pngs");
var ownershipPngArgs = new List<string>
{
"--drawing-path", ownershipDwg,
"--output-dir", ownershipPngDir,
"--include-annotations-in-bounds"
};
ownershipPngArgs.AddRange(BuildViewBoundsArgs(pngRegions));
commands.Add(await RunDotnetProjectAsync(
DrawingToolProject("DwgViewPngExporter"),
ownershipPngArgs,
"24_export_ownership_view_pngs"));
var ownershipPng = RequireFile(Path.Combine(ownershipPngDir, "all_views.png"), "ownership all-views PNG");
var pipelineManifest = new
{
schema = "agent4.drawing_assembly.full_pipeline.v1",
scope = "assembly_drawing_only",
policy = "DWG files are processed only by backend tools. AI receives PNG images and structured summaries, never DWG files.",
student_dwg = studentDwg,
model_path = modelPath,
output_dir = outputDir,
pipeline_dir = pipelineDir,
generated_inputs = new
{
student_view_image = studentViewPng,
ownership_image = ownershipPng,
dwg_extraction_dir = alignedStudentExtraction,
mechanical_context_dir = mechanicalContextDir,
student_view_png_dir = studentPngDir,
ownership_view_png_dir = ownershipPngDir,
final_solidworks_dwg = finalSwDrawingDwg,
final_solidworks_extraction_dir = finalSwExtraction
},
commands
};
WriteJson(Path.Combine(pipelineDir, "pipeline_manifest.json"), pipelineManifest);
var analysis = await AnalyzeAsync(new DrawingAssemblyAnalysisRequest
{
ViewId = "main_view",
WorkDir = pipelineDir,
StudentViewImage = studentViewPng,
OwnershipImage = ownershipPng,
StudentDwgPath = alignedStudentDwg,
DwgExtractionDir = alignedStudentExtraction,
MechanicalContextDir = mechanicalContextDir,
OutputDir = outputDir,
AiModel = request.AiModel,
AiTextModel = request.AiTextModel
});
analysis.Stage = analysis.Ok ? "full_pipeline_completed" : "full_pipeline_ai_failed";
analysis.Message = analysis.Ok
? "Full drawing assembly diagnostic completed."
: analysis.Message;
analysis.PipelineManifestPath = Path.Combine(pipelineDir, "pipeline_manifest.json");
analysis.StudentDwgPath = studentDwg;
analysis.ModelPath = modelPath;
analysis.Artifacts = BuildArtifacts(outputDir);
WriteJson(Path.Combine(outputDir, "analysis_result.json"), analysis);
WriteLatestPointer(outputDir);
return analysis;
}
public JsonNode BuildToleranceContext(DrawingToleranceContextRequest request)
{
var dwgDir = ResolveOptionalDir(request.DwgExtractionDir);
var context = BuildToleranceAssessmentContext(
dwgDir,
ResolveOptionalDir(request.MechanicalContextDir),
BuildDrawingOwnershipContext(ResolveOptionalDir(request.OwnershipContextDir))) as JsonObject ?? new JsonObject();
AttachToleranceRequestPolicy(context, request, dwgDir);
if (!string.IsNullOrWhiteSpace(request.OutputDir))
{
var outputDir = PrepareOutputDir(request.OutputDir, "drawing-assembly-diagnostics");
WriteJson(Path.Combine(outputDir, "tolerance_assessment_context.json"), context);
}
return context;
}
public JsonNode BuildInterfaceDrawingContext(DrawingToleranceContextRequest request)
{
var dwgDir = ResolveOptionalDir(request.DwgExtractionDir);
var mechanicalDir = ResolveOptionalDir(request.MechanicalContextDir);
var ownership = BuildDrawingOwnershipContext(ResolveOptionalDir(request.OwnershipContextDir));
var assembly = BuildAssemblyFunctionContext(mechanicalDir);
var tolerance = BuildToleranceAssessmentContext(dwgDir, mechanicalDir, ownership);
var roughness = BuildRoughnessAssessmentContext(dwgDir, assembly, ownership);
var result = new JsonObject
{
["schema"] = "agent4.interface_drawing_context.v1",
["request_policy"] = BuildInterfaceDrawingRequestPolicy(request, dwgDir),
["assembly_function_context"] = assembly,
["drawing_ownership_context"] = ownership.Summary.DeepClone(),
["tolerance_assessment_context"] = tolerance,
["roughness_assessment_context"] = roughness
};
if (!string.IsNullOrWhiteSpace(request.OutputDir))
{
var outputDir = PrepareOutputDir(request.OutputDir, "drawing-assembly-diagnostics");
WriteJson(Path.Combine(outputDir, "interface_drawing_context.json"), result);
WriteJson(Path.Combine(outputDir, "tolerance_assessment_context.json"), tolerance);
WriteJson(Path.Combine(outputDir, "roughness_assessment_context.json"), roughness);
WriteJson(Path.Combine(outputDir, "assembly_function_context.json"), assembly);
WriteJson(Path.Combine(outputDir, "drawing_ownership_context.json"), ownership.Summary);
}
return result;
}
public async Task<JsonNode> AssessTolerancesAsync(DrawingToleranceAssessmentRunRequest request)
{
var outputDir = PrepareOutputDir(request.OutputDir, "drawing-assembly-diagnostics");
var dir = Path.Combine(outputDir, "tolerance_assessment");
Directory.CreateDirectory(dir);
var context = BuildInterfaceDrawingContext(new DrawingToleranceContextRequest
{
StudentDwgPath = request.StudentDwgPath,
DwgExtractionDir = request.DwgExtractionDir,
MechanicalContextDir = request.MechanicalContextDir,
OwnershipContextDir = request.OwnershipContextDir,
OutputDir = outputDir
}) as JsonObject ?? new JsonObject();
var deterministic = BuildDeterministicToleranceDiagnostic(context, request);
WriteJson(Path.Combine(dir, "tolerance_diagnostic_candidates.json"), deterministic);
JsonNode aiAssessment = new JsonObject
{
["enabled"] = false,
["reason"] = "AI assessment was not requested. Deterministic over-annotation, missing and binding-status checks were generated."
};
if (request.UseAi)
{
aiAssessment = await RunToleranceAiAssessmentAsync(dir, request, context, deterministic);
}
var result = new JsonObject
{
["schema"] = "agent4.drawing_assembly.tolerance_diagnostic_result.v1",
["status"] = "ok",
["output_dir"] = outputDir,
["context_path"] = Path.Combine(outputDir, "interface_drawing_context.json"),
["candidate_path"] = Path.Combine(dir, "tolerance_diagnostic_candidates.json"),
["ai_assessment_path"] = request.UseAi ? Path.Combine(dir, "tolerance_ai_assessment.json") : "",
["deterministic_result"] = deterministic,
["ai_assessment"] = aiAssessment,
["final_assessments"] = MergeToleranceAssessments(deterministic, aiAssessment)
};
WriteJson(Path.Combine(dir, "tolerance_diagnostic_result.json"), result);
File.WriteAllText(Path.Combine(dir, "tolerance_diagnostic_report.md"), BuildToleranceDiagnosticMarkdown(result), new UTF8Encoding(false));
return result;
}
static JsonObject BuildDeterministicToleranceDiagnostic(JsonObject interfaceContext, DrawingToleranceAssessmentRunRequest request)
{
var toleranceContext = interfaceContext["tolerance_assessment_context"] as JsonObject ?? new JsonObject();
var candidates = toleranceContext["tolerance_candidates"] as JsonArray ?? new JsonArray();
var unannotated = toleranceContext["unannotated_interface_candidates"] as JsonArray ?? new JsonArray();
var overAnnotated = new JsonArray();
var bound = new JsonArray();
var ambiguous = new JsonArray();
var missing = new JsonArray();
var all = new JsonArray();
foreach (var candidate in candidates.OfType<JsonObject>())
{
var status = JsonString(candidate["binding_status"]);
var assessment = BuildToleranceCandidateAssessment(candidate, status);
all.Add(assessment.DeepClone());
var kind = JsonString(assessment["issue_type"]);
if (kind == "over_annotated_or_unbound_tolerance")
overAnnotated.Add(assessment.DeepClone());
else if (kind == "bound_tolerance_needs_ai_assessment")
bound.Add(assessment.DeepClone());
else
ambiguous.Add(assessment.DeepClone());
}
foreach (var item in unannotated.OfType<JsonObject>())
{
var assessment = BuildMissingToleranceAssessment(item);
missing.Add(assessment.DeepClone());
all.Add(assessment.DeepClone());
}
return new JsonObject
{
["schema"] = "agent4.drawing_assembly.deterministic_tolerance_diagnostic.v1",
["policy"] = new JsonArray
{
"2D tolerance annotation without a matching 3D external interface is an over-annotation candidate.",
"3D external cylindrical interface without a matching DWG tolerance is a missing-tolerance candidate.",
"A uniquely bound tolerance still requires AI plus fixed tolerance knowledge to decide correct vs incorrect.",
"Ambiguous same-diameter or ownership-weak matches remain needs_review."
},
["use_ai_requested"] = request.UseAi,
["summary"] = new JsonObject
{
["total_assessment_count"] = all.Count,
["over_annotated_count"] = overAnnotated.Count,
["missing_count"] = missing.Count,
["bound_needs_ai_count"] = bound.Count,
["needs_review_count"] = ambiguous.Count
},
["over_annotated_or_unbound_tolerances"] = overAnnotated,
["missing_tolerance_candidates"] = missing,
["bound_tolerances_requiring_ai"] = bound,
["ambiguous_tolerance_bindings"] = ambiguous,
["all_assessments"] = all
};
}
static JsonObject BuildToleranceCandidateAssessment(JsonObject candidate, string bindingStatus)
{
var annotation = candidate["dwg_annotation"] as JsonObject ?? new JsonObject();
var related = candidate["related_3d_interfaces"] as JsonArray ?? new JsonArray();
var best = related.OfType<JsonObject>()
.Where(item => item["is_best_binding_candidate"]?.GetValue<bool>() == true)
.ToList();
var hasAny3d = related.Count > 0;
var hasUniqueBest = best.Count == 1;
var id = FirstNonEmpty(JsonString(candidate["candidate_id"]), $"tolerance_{JsonString(annotation["handle"])}");
if (!hasAny3d || bindingStatus == "no_3d_diameter_match")
{
return new JsonObject
{
["assessment_id"] = id,
["issue_type"] = "over_annotated_or_unbound_tolerance",
["verdict"] = "incorrect",
["confidence"] = "medium",
["dwg_annotation"] = annotation.DeepClone(),
["binding_status"] = bindingStatus,
["reason"] = "该二维公差没有匹配到三维外部接触接口,按当前证据属于疑似多标/无效标注。",
["required_next_step"] = "若认为该标注有效,需要补充二维线条归属或三维接触接口证据。"
};
}
if (hasUniqueBest && (bindingStatus == "matched_by_diameter_and_drawing_ownership" || bindingStatus == "matched_one_interface_group_by_diameter_only"))
{
return new JsonObject
{
["assessment_id"] = id,
["issue_type"] = "bound_tolerance_needs_ai_assessment",
["verdict"] = "needs_review",
["confidence"] = bindingStatus == "matched_by_diameter_and_drawing_ownership" ? "high" : "medium",
["dwg_annotation"] = annotation.DeepClone(),
["best_brep_interface_binding"] = best[0].DeepClone(),
["candidate_brep_interface_bindings"] = related.DeepClone(),
["binding_status"] = bindingStatus,
["reason"] = "二维公差已能绑定到三维接口;是否错标需要结合接口功能、连接方式和固定公差知识判断。",
["required_next_step"] = "run_ai_tolerance_assessment"
};
}
return new JsonObject
{
["assessment_id"] = id,
["issue_type"] = "ambiguous_tolerance_binding",
["verdict"] = "needs_review",
["confidence"] = "low",
["dwg_annotation"] = annotation.DeepClone(),
["candidate_brep_interface_bindings"] = related.DeepClone(),
["binding_status"] = bindingStatus,
["reason"] = "该公差匹配到多个或弱证据三维接口,当前不能唯一确定作用对象。",
["required_next_step"] = "需要更精确的二维线条归属、多视图位置匹配或 B-rep 投影位置匹配。"
};
}
static JsonObject BuildMissingToleranceAssessment(JsonObject candidate)
{
var related = candidate["related_3d_interface"] as JsonObject ?? new JsonObject();
var groupId = JsonString(related["interface_group_id"]);
return new JsonObject
{
["assessment_id"] = FirstNonEmpty(JsonString(candidate["candidate_id"]), $"missing_tolerance_{groupId}"),
["issue_type"] = "missing_tolerance_for_3d_interface",
["verdict"] = "missing",
["confidence"] = string.Equals(JsonString(related["function_binding_status"]), "available", StringComparison.OrdinalIgnoreCase) ? "medium" : "low",
["related_3d_interface"] = related.DeepClone(),
["reason"] = "三维外部圆柱接触接口在当前多视图公差候选中没有找到对应二维公差标注,按当前流程列为疑似漏标。",
["required_next_step"] = "AI 可结合接口功能、连接方式和固定公差知识确认是否确实需要标注。"
};
}
async Task<JsonNode> RunToleranceAiAssessmentAsync(
string dir,
DrawingToleranceAssessmentRunRequest request,
JsonObject context,
JsonObject deterministic)
{
var openAi = MechanicalOpenAiSettings.From(_configuration);
if (!openAi.HasApiKey)
{
var disabled = new JsonObject
{
["enabled"] = false,
["reason"] = "AI API key is not configured; only deterministic tolerance diagnostics were generated."
};
WriteJson(Path.Combine(dir, "tolerance_ai_assessment.json"), disabled);
return disabled;
}
var prompt = BuildToleranceAssessmentPrompt(context, deterministic);
File.WriteAllText(Path.Combine(dir, "tolerance_ai_prompt.md"), prompt, new UTF8Encoding(false));
var response = await DrawingAssemblyAiClient.CallAsync(
openAi.ResolveTextModel(request.AiTextModel),
openAi.RequireApiKey(),
openAi.BaseUrl,
openAi.ApiMode,
openAi.Proxy,
prompt,
[],
0,
Path.Combine(dir, "tolerance_ai_response.json"));
var parsed = ExtractJsonOrFallback(response.Text, new JsonObject
{
["schema"] = "agent4.drawing_assembly.tolerance_ai_assessment.v1",
["status"] = response.Status,
["message"] = response.Message,
["assessments"] = new JsonArray()
});
if (parsed is JsonObject obj)
{
obj["enabled"] = true;
obj["status"] = response.Status;
obj["message"] = response.Message;
obj["raw_response_path"] = response.RawResponsePath;
}
WriteJson(Path.Combine(dir, "tolerance_ai_assessment.json"), parsed);
return parsed;
}
static string BuildToleranceAssessmentPrompt(JsonObject context, JsonObject deterministic)
{
return $$"""
你是机械装配二维图公差查错专家。只判断已经绑定到三维接口的公差是否错标;不要重判接触是否存在。
固定公差知识:
- 几何接触本身不能推出具体 H7/g6 等代号。
- 间隙配合通常用于需要装配可行性、可拆卸、相对转动/滑动、普通定位或导向但不要求压紧固定的孔轴关系。
- 过渡配合通常用于较高定心精度、需要较小间隙或轻微过盈但仍需装拆的定位关系。
- 过盈/压入配合只有在接口需要固定、传递载荷/扭矩、禁止相对运动,并且没有其他可靠正向固定方式时才可作为候选。
- 如果有螺钉、键、销、挡圈、轴肩、压板、焊接、胶接等其他固定方式,不能仅因接触就要求过盈。
- 同一个三维接口若二维多视图没有任何对应公差,按漏标候选处理;但最终是否 missing 要看接口功能是否真的需要尺寸/配合控制。
- 二维公差若对应不到任何三维外部接口,按多标/无效标注候选处理。
- 证据不足时输出 needs_review,不要臆造三维中不存在的信息。
请只输出严格 JSON
```json
{
"schema": "agent4.drawing_assembly.tolerance_ai_assessment.v1",
"assessments": [
{
"assessment_id": "",
"verdict": "correct|incorrect|missing|needs_review",
"issue_type": "wrong_fit|missing_tolerance|over_annotated|ambiguous_binding|correct",
"expected_tolerance_intent": "clearance|transition|interference|non_fit_clearance|not_required|needs_review",
"reason": "",
"evidence_chain": [],
"confidence": "low|medium|high",
"missing_facts": []
}
]
}
```
证据上下文:
```json
{{ToPromptJson(context, 30000)}}
```
确定性候选:
```json
{{ToPromptJson(deterministic, 24000)}}
```
""";
}
static JsonArray MergeToleranceAssessments(JsonObject deterministic, JsonNode aiAssessment)
{
var result = new JsonArray();
var aiById = new Dictionary<string, JsonObject>(StringComparer.OrdinalIgnoreCase);
if (aiAssessment?["assessments"] is JsonArray aiItems)
{
foreach (var item in aiItems.OfType<JsonObject>())
{
var id = JsonString(item["assessment_id"]);
if (!string.IsNullOrWhiteSpace(id))
aiById[id] = item;
}
}
if (deterministic["all_assessments"] is not JsonArray all)
return result;
foreach (var item in all.OfType<JsonObject>())
{
var clone = CloneJsonObject(item);
var id = JsonString(clone["assessment_id"]);
if (aiById.TryGetValue(id, out var ai))
{
clone["ai_verdict"] = ai.DeepClone();
clone["verdict"] = ai["verdict"]?.DeepClone();
clone["ai_assessed"] = true;
}
else
{
clone["ai_assessed"] = false;
}
result.Add(clone);
}
return result;
}
static string BuildToleranceDiagnosticMarkdown(JsonObject result)
{
var sb = new StringBuilder();
sb.AppendLine("# 二维装配图公差查错结果");
sb.AppendLine();
var deterministic = result["deterministic_result"] as JsonObject;
var summary = deterministic?["summary"] as JsonObject;
if (summary != null)
{
sb.AppendLine("## 汇总");
sb.AppendLine();
sb.AppendLine($"- 总候选:{JsonInt(summary["total_assessment_count"])}");
sb.AppendLine($"- 疑似多标:{JsonInt(summary["over_annotated_count"])}");
sb.AppendLine($"- 疑似漏标:{JsonInt(summary["missing_count"])}");
sb.AppendLine($"- 已绑定待 AI 判断:{JsonInt(summary["bound_needs_ai_count"])}");
sb.AppendLine($"- 需复核绑定:{JsonInt(summary["needs_review_count"])}");
sb.AppendLine();
}
if (result["final_assessments"] is JsonArray assessments)
{
sb.AppendLine("## 明细");
sb.AppendLine();
foreach (var item in assessments.OfType<JsonObject>())
{
sb.AppendLine($"### {JsonString(item["assessment_id"])}");
sb.AppendLine();
sb.AppendLine($"- 类型:{JsonString(item["issue_type"])}");
sb.AppendLine($"- 判定:{JsonString(item["verdict"])}");
sb.AppendLine($"- 置信度:{JsonString(item["confidence"])}");
sb.AppendLine($"- 原因:{JsonString(item["reason"])}");
if (item["dwg_annotation"] is JsonObject annotation)
sb.AppendLine($"- DWG 标注:handle={JsonString(annotation["handle"])} text={FirstNonEmpty(JsonString(annotation["text"]), JsonString(annotation["raw_text"]))}");
sb.AppendLine();
}
}
return sb.ToString().TrimEnd();
}
static JsonObject CloneJsonObject(JsonObject obj) =>
JsonNode.Parse(obj.ToJsonString()) as JsonObject ?? new JsonObject();
static void AttachToleranceRequestPolicy(JsonObject context, DrawingToleranceContextRequest request, string dwgDir)
{
context["request_policy"] = BuildInterfaceDrawingRequestPolicy(request, dwgDir);
}
static JsonObject BuildInterfaceDrawingRequestPolicy(DrawingToleranceContextRequest request, string dwgDir)
{
var notes = new JsonArray
{
"This endpoint builds/reuses evidence context; it does not replace the full standard-view/ownership pipeline.",
"If student_dwg_path is provided without dwg_extraction_dir, run DWG extraction first or use the full drawing assembly pipeline."
};
return new JsonObject
{
["student_dwg_path"] = request.StudentDwgPath ?? "",
["dwg_extraction_dir"] = dwgDir,
["mechanical_context_dir"] = request.MechanicalContextDir ?? "",
["ownership_context_dir"] = request.OwnershipContextDir ?? "",
["output_dir"] = request.OutputDir ?? "",
["dwg_extraction_available"] = !string.IsNullOrWhiteSpace(dwgDir) && Directory.Exists(dwgDir),
["student_dwg_requires_extraction"] = !string.IsNullOrWhiteSpace(request.StudentDwgPath) && string.IsNullOrWhiteSpace(dwgDir),
["notes"] = notes
};
}
public async Task<DrawingAssemblyAnalysisResult> RunPartFormatAsync(DrawingPartFormatRunRequest request)
{
var studentDwg = RequireExistingFile(request.StudentDwgPath, ".dwg", nameof(request.StudentDwgPath));
var outputDir = PrepareOutputDir(request.OutputDir, "drawing-part-diagnostics");
var pipelineDir = Path.Combine(outputDir, "pipeline");
Directory.CreateDirectory(pipelineDir);
var commands = new List<DrawingAssemblyCommandResult>();
var extractionDir = Path.Combine(pipelineDir, "01_student_dwg_extraction");
var extractArgs = new List<string>
{
"--drawing-path", studentDwg,
"--output-dir", extractionDir,
"--visible", request.Visible.ToString().ToLowerInvariant(),
"--close-after-extract", "true",
"--max-entities", request.MaxEntities.ToString()
};
if (!string.IsNullOrWhiteSpace(request.ProgId))
extractArgs.AddRange(["--prog-id", request.ProgId]);
commands.Add(await RunDotnetProjectAsync(
DrawingToolProject("AutoCadDwgExtractor"),
extractArgs,
"01_extract_part_dwg"));
var facts = new JsonObject
{
["schema"] = "agent4.drawing_part.supplemental_facts.v1",
["scope"] = "part_drawing_format_check",
["source_policy"] = "format errors are judged by backend DWG entity and geometry rules; annotation and representation AI checks are separate",
["dwg_facts"] = BuildDwgFacts(extractionDir)
};
var supplementDir = Path.Combine(outputDir, "supplement");
Directory.CreateDirectory(supplementDir);
var supplementalPath = Path.Combine(supplementDir, "supplemental_facts.json");
WriteJson(supplementalPath, facts);
var input = new DrawingAssemblyViewInput
{
ViewId = "part_drawing",
StudentDwgPath = studentDwg,
DwgExtractionDir = extractionDir,
ProgId = request.ProgId
};
var formatIssues = await BuildFormatIssuesAsync(outputDir, input, facts);
var finalDir = Path.Combine(outputDir, "final");
Directory.CreateDirectory(finalDir);
var finalReportPath = Path.Combine(finalDir, "final_report.md");
var reportText = BuildDrawingPartFormatReport(facts);
File.WriteAllText(finalReportPath, reportText, new UTF8Encoding(false));
var pipelineManifest = new JsonObject
{
["schema"] = "agent4.drawing_part.pipeline_manifest.v1",
["student_dwg_path"] = studentDwg,
["extraction_dir"] = extractionDir,
["commands"] = JsonSerializer.SerializeToNode(commands, JsonOptions)
};
var pipelineManifestPath = Path.Combine(pipelineDir, "pipeline_manifest.json");
WriteJson(pipelineManifestPath, pipelineManifest);
var result = new DrawingAssemblyAnalysisResult
{
Ok = true,
Stage = "format_completed",
Message = "Drawing part format check completed by programmatic DWG line-style rules.",
OutputDir = outputDir,
ViewId = input.ViewId,
StudentDwgPath = studentDwg,
ProbePath = "",
SupplementalFactsPath = supplementalPath,
CandidateIssuesPath = "",
RetrievedRulesPath = "",
FinalReportPath = finalReportPath,
FinalReportMarkdown = reportText,
PipelineManifestPath = pipelineManifestPath,
FormatIssues = formatIssues,
Artifacts = BuildArtifacts(outputDir)
};
WriteJson(Path.Combine(outputDir, "analysis_result.json"), result);
WriteLatestPointer(outputDir, "drawing-part-diagnostics");
return result;
}
public async Task<DrawingAssemblyAnalysisResult> AnalyzeAsync(DrawingAssemblyAnalysisRequest request)
{
var outputDir = PrepareOutputDir(request.OutputDir);
var input = ResolveInput(request, outputDir);
WriteJson(Path.Combine(outputDir, "input", "view_input_manifest.json"), input);
var openAi = MechanicalOpenAiSettings.From(_configuration);
var model = openAi.ResolveModel(request.AiModel);
var textModel = openAi.ResolveTextModel(request.AiTextModel);
var apiKey = openAi.RequireApiKey();
var imagePair = BuildImagePair(input);
var probe = await RunProbeAsync(outputDir, model, openAi, apiKey, input, imagePair);
var facts = BuildSupplementalFacts(outputDir, input, probe);
var candidates = await RunLayeredCandidateAsync(outputDir, model, openAi, apiKey, input, probe, facts, imagePair);
var retrieval = BuildKnowledgeRetrieval(outputDir, candidates, facts);
var final = await RunFinalJudgementAsync(outputDir, textModel, openAi, apiKey, input, probe, facts, candidates, retrieval, imagePair);
var formatIssues = await BuildFormatIssuesAsync(outputDir, input, facts);
var result = new DrawingAssemblyAnalysisResult
{
Ok = final.Status == "ok",
Stage = final.Status == "ok" ? "completed" : "failed",
Message = final.Message,
OutputDir = outputDir,
ViewId = input.ViewId,
StudentViewImage = input.StudentViewImage,
OwnershipImage = input.OwnershipImage,
ProbePath = Path.Combine(outputDir, "probe", "ai_view_probe.json"),
SupplementalFactsPath = Path.Combine(outputDir, "supplement", "supplemental_facts.json"),
CandidateIssuesPath = Path.Combine(outputDir, "candidate", "layered_candidate_issues.json"),
RetrievedRulesPath = Path.Combine(outputDir, "retrieval", "retrieved_rules.json"),
FinalReportPath = final.ReportPath,
FinalReportMarkdown = File.Exists(final.ReportPath) ? File.ReadAllText(final.ReportPath, Encoding.UTF8) : final.Message,
StudentDwgPath = input.StudentDwgPath,
FormatIssues = formatIssues,
Artifacts = BuildArtifacts(outputDir)
};
WriteJson(Path.Combine(outputDir, "analysis_result.json"), result);
WriteLatestPointer(outputDir);
return result;
}
public object ReadLatest()
{
var pointer = Path.Combine(_paths.RuntimeDir, "drawing-assembly-diagnostics", "latest.json");
if (!File.Exists(pointer))
return new { ok = false, found = false, message = "还没有二维装配图后续分析结果。" };
using var doc = JsonDocument.Parse(File.ReadAllText(pointer));
var outputDir = ReadString(doc.RootElement, "output_dir");
var resultPath = Path.Combine(outputDir, "analysis_result.json");
if (!File.Exists(resultPath))
return new { ok = false, found = false, output_dir = outputDir, message = "latest 指向的结果文件不存在。" };
using var result = JsonDocument.Parse(File.ReadAllText(resultPath));
return result.RootElement.Clone();
}
public object ReadPartLatest()
{
var pointer = Path.Combine(_paths.RuntimeDir, "drawing-part-diagnostics", "latest.json");
if (!File.Exists(pointer))
return new { ok = false, found = false, message = "No drawing part diagnostic result yet." };
using var doc = JsonDocument.Parse(File.ReadAllText(pointer));
var outputDir = ReadString(doc.RootElement, "output_dir");
var resultPath = Path.Combine(outputDir, "analysis_result.json");
if (!File.Exists(resultPath))
return new { ok = false, found = false, output_dir = outputDir, message = "Latest drawing part result file does not exist." };
using var result = JsonDocument.Parse(File.ReadAllText(resultPath));
return result.RootElement.Clone();
}
public MechanicalEvidenceImageFile ResolveEvidenceImage(string encodedPath)
{
var path = Encoding.UTF8.GetString(Convert.FromBase64String(encodedPath));
var fullPath = Path.GetFullPath(path);
var runtimeRoot = Path.GetFullPath(_paths.RuntimeDir);
if (!fullPath.StartsWith(runtimeRoot, StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException("image path is outside the runtime directory.");
if (!File.Exists(fullPath))
throw new FileNotFoundException($"image not found: {fullPath}");
return new MechanicalEvidenceImageFile
{
FilePath = fullPath,
ContentType = Path.GetExtension(fullPath).ToLowerInvariant() switch
{
".jpg" or ".jpeg" => "image/jpeg",
".png" => "image/png",
".webp" => "image/webp",
_ => "application/octet-stream"
}
};
}
string DrawingToolProject(string toolName)
{
var project = Path.Combine(_paths.Agent4Root, "tools", "drawing-diagnostics", toolName, toolName + ".csproj");
if (!File.Exists(project))
throw new FileNotFoundException($"drawing diagnostic tool project not found: {project}");
return project;
}
async Task<DrawingAssemblyCommandResult> RunDotnetProjectAsync(string projectPath, IReadOnlyList<string> args, string stage)
{
var processArgs = new List<string> { "run", "--project", projectPath, "-c", "Release", "--" };
processArgs.AddRange(args.Where(arg => !string.IsNullOrWhiteSpace(arg)));
return await RunProcessAsync("dotnet", processArgs, stage, projectPath);
}
async Task<DrawingAssemblyCommandResult> RunPowerShellScriptAsync(string scriptPath, IReadOnlyList<string> args, string stage)
{
if (!File.Exists(scriptPath))
throw new FileNotFoundException($"PowerShell script not found: {scriptPath}");
var processArgs = new List<string> { "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", scriptPath };
processArgs.AddRange(args.Where(arg => !string.IsNullOrWhiteSpace(arg)));
return await RunProcessAsync("powershell", processArgs, stage, scriptPath);
}
async Task<DrawingAssemblyCommandResult> RunProcessAsync(string fileName, IReadOnlyList<string> args, string stage, string toolPath)
{
var startInfo = new ProcessStartInfo
{
FileName = fileName,
WorkingDirectory = _paths.Agent4Root,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true
};
foreach (var arg in args)
startInfo.ArgumentList.Add(arg);
var startedAt = DateTimeOffset.Now;
using var process = Process.Start(startInfo) ?? throw new InvalidOperationException($"failed to start {stage}: {fileName}");
var stdoutTask = process.StandardOutput.ReadToEndAsync();
var stderrTask = process.StandardError.ReadToEndAsync();
await process.WaitForExitAsync();
var completedAt = DateTimeOffset.Now;
var result = new DrawingAssemblyCommandResult
{
Stage = stage,
ToolPath = toolPath,
FileName = fileName,
Arguments = args.ToList(),
ExitCode = process.ExitCode,
StandardOutput = await stdoutTask,
StandardError = await stderrTask,
StartedAt = startedAt,
CompletedAt = completedAt
};
var commandDir = Path.Combine(_paths.RuntimeDir, "drawing-assembly-diagnostics", "command-logs");
Directory.CreateDirectory(commandDir);
WriteJson(Path.Combine(commandDir, $"{DateTime.Now:yyyyMMdd_HHmmssfff}_{SanitizeFileName(stage)}.json"), result);
if (result.ExitCode != 0)
throw new InvalidOperationException($"{stage} failed. exit={result.ExitCode}\n{result.StandardOutput}\n{result.StandardError}");
return result;
}
static string RequireExistingFile(string path, string expectedExtension, string name)
{
if (string.IsNullOrWhiteSpace(path))
throw new FileNotFoundException($"{name} is required.");
var full = Path.GetFullPath(path.Trim().Trim('"'));
if (!File.Exists(full))
throw new FileNotFoundException($"{name} not found: {full}");
if (!string.Equals(Path.GetExtension(full), expectedExtension, StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException($"{name} must be {expectedExtension}: {full}");
return full;
}
static string RequireFile(string path, string description)
{
var full = Path.GetFullPath(path);
if (!File.Exists(full))
throw new FileNotFoundException($"{description} not generated: {full}");
return full;
}
static string ReadPlacedMainViewOrientationName(string candidateResultPath)
{
if (!File.Exists(candidateResultPath))
return "";
try
{
using var doc = JsonDocument.Parse(File.ReadAllText(candidateResultPath));
var root = doc.RootElement;
if (root.TryGetProperty("artifacts", out var artifacts) && artifacts.ValueKind == JsonValueKind.Object)
{
var orientation = ReadString(artifacts, "placed_orientation_name");
if (!string.IsNullOrWhiteSpace(orientation))
return orientation;
var viewName = ReadString(artifacts, "placed_main_view_name");
if (!string.IsNullOrWhiteSpace(viewName))
return viewName;
}
}
catch
{
return "";
}
return "";
}
static DrawingViewBounds ReadMainViewBounds(string extractionDir)
{
var viewRegionsPath = Path.Combine(extractionDir, "view-regions.json");
if (File.Exists(viewRegionsPath))
{
using var doc = JsonDocument.Parse(File.ReadAllText(viewRegionsPath));
if (doc.RootElement.TryGetProperty("main_view_bbox", out var bbox) && TryReadBounds(bbox, out var bounds))
return bounds;
}
var selectionPath = Path.Combine(extractionDir, "main-view-selection.json");
if (File.Exists(selectionPath))
{
using var doc = JsonDocument.Parse(File.ReadAllText(selectionPath));
if (doc.RootElement.TryGetProperty("bbox", out var bbox) && TryReadBounds(bbox, out var bounds))
return bounds;
}
throw new FileNotFoundException($"main-view bounds not found under extraction dir: {extractionDir}");
}
static List<DrawingViewRegion> ReadStudentViewRegions(string extractionDir)
{
var regions = new List<DrawingViewRegion>
{
new("main_view", "main_view", "main_view", ReadMainViewBounds(extractionDir))
};
var viewRegionsPath = Path.Combine(extractionDir, "view-regions.json");
if (!File.Exists(viewRegionsPath))
return regions;
using var doc = JsonDocument.Parse(File.ReadAllText(viewRegionsPath));
var root = doc.RootElement;
if (root.TryGetProperty("orthographic_views", out var orthographicViews) &&
orthographicViews.ValueKind == JsonValueKind.Array)
{
foreach (var item in orthographicViews.EnumerateArray())
{
if (!ReadBool(item, "ok"))
continue;
var kind = ReadString(item, "kind");
if (string.IsNullOrWhiteSpace(kind))
continue;
if (item.TryGetProperty("bbox", out var bbox) && TryReadBounds(bbox, out var bounds))
regions.Add(new DrawingViewRegion(SanitizeViewId(kind), kind, kind, bounds));
}
}
if (root.TryGetProperty("section_views", out var sectionViews) &&
sectionViews.ValueKind == JsonValueKind.Array)
{
foreach (var item in sectionViews.EnumerateArray())
{
if (!ReadBool(item, "ok"))
continue;
var label = FirstNonEmpty(ReadString(item, "label"), "section");
if (item.TryGetProperty("bbox", out var bbox) && TryReadBounds(bbox, out var bounds))
regions.Add(new DrawingViewRegion(
SanitizeViewId("section_" + label),
"section_view",
label,
bounds));
}
}
return DeduplicateRegions(regions);
}
static List<DrawingViewRegion> DeduplicateRegions(List<DrawingViewRegion> regions)
{
var names = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
var result = new List<DrawingViewRegion>();
foreach (var region in regions)
{
var baseId = SanitizeViewId(region.Id);
names.TryGetValue(baseId, out var count);
names[baseId] = count + 1;
var id = count == 0 ? baseId : $"{baseId}_{count + 1:00}";
result.Add(region with { Id = id });
}
return result;
}
static IEnumerable<string> BuildViewBoundsArgs(IEnumerable<DrawingViewRegion> regions)
{
foreach (var region in regions)
{
yield return "--view-bounds-mm";
yield return region.Id + "=" + region.Bounds.ToCliString();
}
}
static (double X, double Y) InitialProjectedViewPlacement(DrawingViewBounds mainBounds, DrawingViewBounds targetBounds, string direction)
{
if (string.Equals(direction, "right", StringComparison.OrdinalIgnoreCase))
return (Math.Max(targetBounds.CenterX, mainBounds.MaxX + Math.Max(20.0, targetBounds.Width / 2.0)), mainBounds.CenterY);
var belowMainY = mainBounds.MinY - Math.Max(20.0, targetBounds.Height / 2.0);
return (mainBounds.CenterX, Math.Min(targetBounds.CenterY, belowMainY));
}
static DrawingViewBounds UnionBounds(IEnumerable<DrawingViewBounds> bounds)
{
var list = bounds.ToList();
if (list.Count == 0)
throw new InvalidOperationException("no view bounds available for PNG export.");
return new DrawingViewBounds(
list.Min(item => item.MinX),
list.Min(item => item.MinY),
list.Max(item => item.MaxX),
list.Max(item => item.MaxY));
}
static bool HasSectionCutSymbols(string path)
{
if (!File.Exists(path))
return false;
try
{
using var doc = JsonDocument.Parse(File.ReadAllText(path));
return doc.RootElement.ValueKind == JsonValueKind.Array &&
doc.RootElement.EnumerateArray().Any(item =>
ReadBool(item, "ok") &&
string.Equals(ReadString(item, "kind"), "section_cut_symbol_pair", StringComparison.OrdinalIgnoreCase));
}
catch
{
return false;
}
}
static bool HasSectionViewRegions(string path)
{
if (!File.Exists(path))
return false;
try
{
using var doc = JsonDocument.Parse(File.ReadAllText(path));
return doc.RootElement.TryGetProperty("section_views", out var sectionViews) &&
sectionViews.ValueKind == JsonValueKind.Array &&
sectionViews.EnumerateArray().Any(item => ReadBool(item, "ok"));
}
catch
{
return false;
}
}
static bool TryReadBounds(JsonElement element, out DrawingViewBounds bounds)
{
bounds = default;
if (!TryReadDouble(element, "min_x", out var minX) ||
!TryReadDouble(element, "min_y", out var minY) ||
!TryReadDouble(element, "max_x", out var maxX) ||
!TryReadDouble(element, "max_y", out var maxY))
return false;
bounds = new DrawingViewBounds(minX, minY, maxX, maxY);
return true;
}
static bool TryReadDouble(JsonElement element, string name, out double value)
{
value = 0;
return element.ValueKind == JsonValueKind.Object &&
element.TryGetProperty(name, out var property) &&
property.ValueKind == JsonValueKind.Number &&
property.TryGetDouble(out value);
}
static bool ReadBool(JsonElement element, string name)
{
return element.ValueKind == JsonValueKind.Object &&
element.TryGetProperty(name, out var value) &&
value.ValueKind == JsonValueKind.True;
}
static string SanitizeViewId(string value)
{
var id = Regex.Replace(value.Trim().ToLowerInvariant(), @"[^a-z0-9_]+", "_", RegexOptions.CultureInvariant);
id = Regex.Replace(id, "_+", "_", RegexOptions.CultureInvariant).Trim('_');
return string.IsNullOrWhiteSpace(id) ? "view" : id;
}
static string SanitizeFileName(string value)
{
var invalid = Path.GetInvalidFileNameChars().ToHashSet();
var chars = value.Select(ch => invalid.Contains(ch) ? '_' : ch).ToArray();
var result = new string(chars).Trim();
return string.IsNullOrWhiteSpace(result) ? "item" : result;
}
DrawingAssemblyViewInput ResolveInput(DrawingAssemblyAnalysisRequest request, string outputDir)
{
if (!string.IsNullOrWhiteSpace(request.DwgPath) || !string.IsNullOrWhiteSpace(request.DrawingPath))
throw new InvalidOperationException("二维装配图 AI 后续分析不接受 DWG 作为 AI 输入;请传学生原始视图 PNG 和归属/上色 PNG。");
var workDir = ResolveOptionalDir(request.WorkDir);
var studentImage = ResolveImagePath(request.StudentViewImage, workDir, ["student", "raw", "original", "原始", "学生"]);
var ownershipImage = ResolveImagePath(request.OwnershipImage, workDir, ["ownership", "colored", "color", "归属", "上色"]);
EnsureImageFile(studentImage, nameof(request.StudentViewImage));
EnsureImageFile(ownershipImage, nameof(request.OwnershipImage));
var dwgExtractionDir = ResolveOptionalDir(request.DwgExtractionDir);
var mechanicalContextDir = ResolveOptionalDir(request.MechanicalContextDir);
return new DrawingAssemblyViewInput
{
Schema = "agent4.drawing_assembly.view_input.v1",
Scope = "assembly_drawing_only",
ViewId = FirstNonEmpty(request.ViewId, "view_001"),
StudentViewImage = studentImage,
OwnershipImage = ownershipImage,
StudentDwgPath = ResolveOptionalFile(request.StudentDwgPath),
WorkDir = workDir,
DwgExtractionDir = dwgExtractionDir,
MechanicalContextDir = mechanicalContextDir,
OutputDir = outputDir,
Notes =
[
"当前二维后续分析只针对装配图。",
"AI 输入只允许学生原始视图图片和归属/上色图,不允许直接传 DWG。",
"DWG 抽取结果只作为后端整理后的结构化事实参与。"
]
};
}
async Task<JsonNode> RunProbeAsync(
string outputDir,
string model,
MechanicalOpenAiSettings openAi,
string apiKey,
DrawingAssemblyViewInput input,
IReadOnlyList<MechanicalEvidenceImage> images)
{
var prompt = BuildProbePrompt(input) + BuildStaticScoutBlocksForPrompt();
var dir = Path.Combine(outputDir, "probe");
Directory.CreateDirectory(dir);
File.WriteAllText(Path.Combine(dir, "ai_view_probe_prompt.md"), prompt, new UTF8Encoding(false));
var response = await DrawingAssemblyAiClient.CallAsync(
model,
apiKey,
openAi.BaseUrl,
openAi.ApiMode,
openAi.Proxy,
prompt,
images,
Math.Min(2, openAi.MaxImages),
Path.Combine(dir, "ai_view_probe_response.json"));
File.WriteAllText(Path.Combine(dir, "ai_view_probe_text.md"), response.Text, new UTF8Encoding(false));
var node = ExtractJsonOrFallback(response.Text, BuildFallbackProbe());
WriteJson(Path.Combine(dir, "ai_view_probe.json"), node);
return node;
}
JsonNode BuildSupplementalFacts(string outputDir, DrawingAssemblyViewInput input, JsonNode probe)
{
var dir = Path.Combine(outputDir, "supplement");
Directory.CreateDirectory(dir);
var dwgFacts = BuildDwgFacts(input.DwgExtractionDir);
var mechanicalFacts = BuildMechanicalFacts(input.MechanicalContextDir);
var assemblyFunctionContext = BuildAssemblyFunctionContext(input.MechanicalContextDir);
var ownershipContext = BuildDrawingOwnershipContext(input.WorkDir);
var toleranceContext = BuildToleranceAssessmentContext(input.DwgExtractionDir, input.MechanicalContextDir, ownershipContext);
var roughnessContext = BuildRoughnessAssessmentContext(input.DwgExtractionDir, assemblyFunctionContext, ownershipContext);
var facts = new JsonObject
{
["schema"] = "agent4.drawing_assembly.supplemental_facts.v1",
["scope"] = "assembly_drawing_only",
["view_id"] = input.ViewId,
["source_policy"] = "AI 不直接接收 DWG;这里只汇总 DWG 抽取 JSON 和三维语义 JSON 的结构化摘要。",
["authority_policy"] = new JsonObject
{
["raw_geometry_and_annotations"] = "dwg_facts and mechanical_3d_facts",
["whole_component_purpose"] = "assembly_function_context.component_function_profiles",
["physical_contact_function"] = "assembly_function_context.interface_function_profiles",
["connection_method_and_interference_need"] = "interface profile connection_assessment",
["drawing_to_interface_binding"] = "tolerance_assessment_context and roughness_assessment_context",
["conflict_rule"] = "More specific evidence does not overwrite another layer; conflicts remain explicit and force needs_review."
},
["probe_needs"] = probe.DeepClone(),
["requested_3d_fact_queries"] = ExtractProbeNeedTexts(probe, ["needed_3d_facts", "annotation_check_needs", "representation_check_needs"]),
["requested_dwg_fact_queries"] = ExtractProbeNeedTexts(probe, ["needed_dwg_facts", "format_check_needs", "annotation_check_needs"]),
["dwg_facts"] = dwgFacts,
["mechanical_3d_facts"] = mechanicalFacts,
["assembly_function_context"] = assemblyFunctionContext,
["drawing_ownership_context"] = ownershipContext.Summary,
["tolerance_assessment_context"] = toleranceContext,
["roughness_assessment_context"] = roughnessContext,
["fact_limits"] = new JsonArray
{
"当前补充事实为摘要级,后续可按 ai_view_probe.needed_3d_facts 精确抽取零件、孔组、配合面和剖视需求。",
"若事实不足,AI 必须输出 needs_review,不得臆造 DWG 或三维中不存在的信息。"
}
};
WriteJson(Path.Combine(dir, "tolerance_assessment_context.json"), toleranceContext);
WriteJson(Path.Combine(dir, "roughness_assessment_context.json"), roughnessContext);
WriteJson(Path.Combine(dir, "assembly_function_context.json"), assemblyFunctionContext);
WriteJson(Path.Combine(dir, "drawing_ownership_context.json"), ownershipContext.Summary);
WriteJson(Path.Combine(dir, "supplemental_facts.json"), facts);
return facts;
}
static JsonArray ExtractProbeNeedTexts(JsonNode probe, IReadOnlyList<string> keys)
{
var result = new JsonArray();
if (probe is not JsonObject obj)
return result;
foreach (var key in keys)
{
if (!obj.TryGetPropertyValue(key, out var value) || value == null)
continue;
if (value is JsonArray arr)
{
foreach (var item in arr)
{
var text = item?.ToJsonString() ?? "";
if (!string.IsNullOrWhiteSpace(text))
result.Add(Truncate(text, 500));
}
}
else
{
result.Add(Truncate(value.ToJsonString(), 500));
}
}
return result;
}
async Task<JsonNode> RunLayeredCandidateAsync(
string outputDir,
string model,
MechanicalOpenAiSettings openAi,
string apiKey,
DrawingAssemblyViewInput input,
JsonNode probe,
JsonNode facts,
IReadOnlyList<MechanicalEvidenceImage> images)
{
var prompt = BuildLayeredCandidatePrompt(input, probe, facts) + BuildDynamicScoutBlocksForPrompt();
var dir = Path.Combine(outputDir, "candidate");
Directory.CreateDirectory(dir);
File.WriteAllText(Path.Combine(dir, "layered_candidate_prompt.md"), prompt, new UTF8Encoding(false));
var response = await DrawingAssemblyAiClient.CallAsync(
model,
apiKey,
openAi.BaseUrl,
openAi.ApiMode,
openAi.Proxy,
prompt,
images,
Math.Min(2, openAi.MaxImages),
Path.Combine(dir, "layered_candidate_response.json"));
File.WriteAllText(Path.Combine(dir, "layered_candidate_text.md"), response.Text, new UTF8Encoding(false));
var node = ExtractJsonOrFallback(response.Text, BuildFallbackCandidates());
SuppressAiFormatErrors(node);
WriteJson(Path.Combine(dir, "layered_candidate_issues.json"), node);
WriteJson(Path.Combine(dir, "retrieval_queries.json"), BuildRetrievalQueries(node, facts));
return node;
}
static void SuppressAiFormatErrors(JsonNode node)
{
if (node is not JsonObject obj)
return;
obj["format_errors"] = new JsonArray();
obj["format_policy"] = "format errors are judged by backend programmatic DWG geometry/entity rules; AI candidates are limited to annotation_errors and representation_errors";
}
JsonNode BuildKnowledgeRetrieval(string outputDir, JsonNode candidates, JsonNode facts)
{
var dir = Path.Combine(outputDir, "retrieval");
Directory.CreateDirectory(dir);
var queries = BuildRetrievalQueries(candidates, facts);
var rules = new JsonObject
{
["schema"] = "agent4.drawing_assembly.knowledge_retrieval.v1",
["retrieval_policy"] = "按候选问题检索制图、尺寸、公差、粗糙度、剖视表达等本地知识;结果仅供最终判定引用。",
["queries"] = queries.DeepClone(),
["results"] = SearchKnowledge(queries)
};
WriteJson(Path.Combine(dir, "retrieval_queries.json"), queries);
WriteJson(Path.Combine(dir, "retrieved_rules.json"), rules);
return rules;
}
async Task<DrawingAssemblyFinalResult> RunFinalJudgementAsync(
string outputDir,
string model,
MechanicalOpenAiSettings openAi,
string apiKey,
DrawingAssemblyViewInput input,
JsonNode probe,
JsonNode facts,
JsonNode candidates,
JsonNode retrieval,
IReadOnlyList<MechanicalEvidenceImage> images)
{
var dir = Path.Combine(outputDir, "final");
Directory.CreateDirectory(dir);
var prompt = BuildFinalPrompt(input, probe, facts, candidates, retrieval) + BuildFinalKnowledgeInstructionForPrompt();
File.WriteAllText(Path.Combine(dir, "final_prompt.md"), prompt, new UTF8Encoding(false));
var response = await DrawingAssemblyAiClient.CallAsync(
model,
apiKey,
openAi.BaseUrl,
openAi.ApiMode,
openAi.Proxy,
prompt,
images,
Math.Min(2, openAi.MaxImages),
Path.Combine(dir, "final_response.json"));
var programmaticLineStyleReport = BuildProgrammaticFormatReport(facts);
var reportText = BuildFinalReportText(response.Text, programmaticLineStyleReport);
var reportPath = Path.Combine(dir, "final_report.md");
File.WriteAllText(reportPath, reportText, new UTF8Encoding(false));
WriteJson(Path.Combine(dir, "final_report.json"), new
{
schema = "agent4.drawing_assembly.final_report.v1",
status = response.Status,
message = response.Message,
report_path = reportPath,
raw_response_path = response.RawResponsePath,
programmatic_format_check_appended = !string.IsNullOrWhiteSpace(programmaticLineStyleReport),
programmatic_line_style_check_appended = !string.IsNullOrWhiteSpace(programmaticLineStyleReport)
});
return new DrawingAssemblyFinalResult
{
Status = response.Status,
Message = response.Message,
ReportPath = reportPath
};
}
static string BuildFinalReportText(string aiReportText, string programmaticLineStyleReport)
{
var sb = new StringBuilder();
sb.AppendLine("# 二维装配图查错结果");
sb.AppendLine();
if (!string.IsNullOrWhiteSpace(programmaticLineStyleReport))
{
sb.AppendLine(programmaticLineStyleReport.Trim());
sb.AppendLine();
}
else
{
sb.AppendLine("## 1. 格式错误");
sb.AppendLine();
sb.AppendLine("未取得可用的 DWG 程序化线型检查结果,本节不由 AI 代判。");
sb.AppendLine();
}
sb.AppendLine(aiReportText.Trim());
return sb.ToString().TrimEnd();
}
static string BuildProgrammaticFormatReport(JsonNode facts)
{
var check = facts["dwg_facts"]?["line_style_rule_check"] as JsonObject;
if (check == null)
return "";
var available = check["available"]?.GetValue<bool>() ?? false;
if (!available)
return "";
var issueCount = check["summary"]?["issue_count"]?.GetValue<int>() ?? 0;
var sb = new StringBuilder();
sb.AppendLine("## 1. 格式错误");
sb.AppendLine();
sb.AppendLine("来源:DWG 结构化抽取后的程序化规则判定;本节不使用 AI 判定。");
sb.AppendLine($"结论:{(issueCount == 0 ? "未发现确定的线型/线宽格式错误。" : $"发现 {issueCount} 条确定问题。")}");
if (check["classes"] is JsonArray classes)
{
foreach (var cls in classes.OfType<JsonObject>())
{
var classIssueCount = cls["issue_count"]?.GetValue<int>() ?? 0;
if (classIssueCount <= 0)
continue;
sb.AppendLine();
sb.AppendLine($"- {cls["label"]?.GetValue<string>() ?? "line class"}:应为 {cls["expected_style"]?.GetValue<string>() ?? "expected style"},发现 {classIssueCount} 条问题。");
if (cls["issues"] is not JsonArray issues)
continue;
foreach (var issue in issues.OfType<JsonObject>().Take(8))
{
var handle = issue["handle"]?.GetValue<string>() ?? "";
var layer = issue["layer"]?.GetValue<string>() ?? "";
var linetype = issue["linetype"]?.GetValue<string>() ?? "";
var lineweight = issue["lineweight"]?.ToJsonString() ?? "null";
var reason = issue["reason"]?.GetValue<string>() ?? "";
sb.AppendLine($" - 图元={handle}, 图层={layer}, 线型={linetype}, 线宽={lineweight}{reason}");
}
}
}
sb.AppendLine();
sb.AppendLine("说明:线型/线宽格式错误由程序直接使用 CAD 几何、实体类型、linetype、lineweight 和归属字段判定;标注错误和绘制表达错误仍由 AI 结合图片、结构化事实和知识库分析。");
return sb.ToString().TrimEnd();
}
static string BuildProgrammaticLineStyleReport(JsonNode facts)
{
var check = facts["dwg_facts"]?["line_style_rule_check"] as JsonObject;
if (check == null)
return "";
var available = check["available"]?.GetValue<bool>() ?? false;
if (!available)
return "";
var issueCount = check["summary"]?["issue_count"]?.GetValue<int>() ?? 0;
var sb = new StringBuilder();
sb.AppendLine("## 程序化线条线型检查");
sb.AppendLine();
sb.AppendLine("来源:DWG 结构化抽取后的规则程序判断,未使用 AI 判定本节结论。");
sb.AppendLine($"结论:{(issueCount == 0 ? "未发现确定的线型/线宽错误。" : $"发现 {issueCount} 条确定问题。")}");
if (check["classes"] is JsonArray classes)
{
foreach (var cls in classes.OfType<JsonObject>())
{
var classIssueCount = cls["issue_count"]?.GetValue<int>() ?? 0;
if (classIssueCount <= 0)
continue;
sb.AppendLine();
sb.AppendLine($"- {cls["label"]?.GetValue<string>() ?? "line class"}:应为 {cls["expected_style"]?.GetValue<string>() ?? "expected style"},发现 {classIssueCount} 条问题。");
if (cls["issues"] is not JsonArray issues)
continue;
foreach (var issue in issues.OfType<JsonObject>().Take(8))
{
var handle = issue["handle"]?.GetValue<string>() ?? "";
var layer = issue["layer"]?.GetValue<string>() ?? "";
var linetype = issue["linetype"]?.GetValue<string>() ?? "";
var lineweight = issue["lineweight"]?.ToJsonString() ?? "null";
var reason = issue["reason"]?.GetValue<string>() ?? "";
sb.AppendLine($" - 图元={handle}, 图层={layer}, 线型={linetype}, 线宽={lineweight}{reason}");
}
}
}
sb.AppendLine();
sb.AppendLine("说明:螺纹牙底线已启用几何规则识别和缺失检查;齿轮相关线条已预留归属信息接入口,未取得可靠归属时不扩大推断。");
return sb.ToString().TrimEnd();
}
static string BuildDrawingPartFormatReport(JsonNode facts)
{
var formatReport = BuildProgrammaticFormatReport(facts);
var sb = new StringBuilder();
sb.AppendLine("# 二维零件图查错结果");
sb.AppendLine();
if (!string.IsNullOrWhiteSpace(formatReport))
{
sb.AppendLine(formatReport.Trim());
}
else
{
sb.AppendLine("## 1. 格式错误");
sb.AppendLine();
sb.AppendLine("未取得可用的 DWG 程序化线型/线宽检查结果,本节不由 AI 代判。");
}
sb.AppendLine();
sb.AppendLine("## 2. 标注错误");
sb.AppendLine();
sb.AppendLine("二维零件图标注错误仍需走 AI 分析;当前接口仅完成线型/线宽格式查错。");
sb.AppendLine();
sb.AppendLine("## 3. 绘制错误");
sb.AppendLine();
sb.AppendLine("二维零件图绘制表达错误仍需走 AI 分析;当前接口仅完成线型/线宽格式查错。");
return sb.ToString().TrimEnd();
}
string BuildStaticScoutBlocksForPrompt()
{
var blocks = BuildDrawingAssemblyStaticScoutBlocks();
return $$"""
【二维装配图查错知识库:第一轮静态侦察块】
当前流程只针对二维装配图,不针对二维零件图。你只能依据下面三个公共静态侦察块提出补充事实需求。
第一轮不得自由发散生成补充信息需求;每一条需求必须能对应到某个 allowed_probe_targets 或 allowed_supplemental_requests。
禁止要求直接读取或传入 DWG;DWG 只能由后端抽取为结构化事实后再提供。
{{ToPromptJson(blocks, 12000)}}
""";
}
string BuildDynamicScoutBlocksForPrompt()
{
var blocks = BuildDrawingAssemblyDynamicScoutBlocks();
return $$"""
【二维装配图查错知识库:第二轮动态侦察块】
第二轮候选问题保留三层 JSON 结构,但 format 必须为空;格式错误由后端程序化规则输出。
AI 只为 annotation 和 representation 生成候选问题。
每个候选问题必须引用一个 dynamic_scout_block 对应的 chunk_id;如果补充事实不足,填入 missing_facts,不要直接判错。
retrieval_query 应结合候选问题、chunk 的动态侦察块和 retrieval_metadata.query_terms 生成。
{{ToPromptJson(blocks, 18000)}}
""";
}
static string BuildFinalKnowledgeInstructionForPrompt() =>
"""
【最终判定知识使用规则】
retrieved_rules source_type=drawing_assembly_diagnostic_index 的命中是结构化知识块。
retrieval_metadata 只用于说明为什么检索到该块;最终判定必须主要引用 content.rule_textcontent.judgement_points content.evidence_requirements
如果候选问题只有检索元命中、没有图片证据或补充事实支撑,应写“需复核”,不要臆造错误。
""";
JsonNode BuildDrawingAssemblyStaticScoutBlocks()
{
var result = new JsonObject
{
["schema"] = "agent4.drawing_assembly.static_scout_blocks.v1",
["scope"] = "assembly_drawing_only",
["blocks"] = new JsonArray()
};
var index = LoadDrawingAssemblyKnowledgeIndex();
if (index?["layers"] is not JsonObject layers)
return result;
var blocks = (JsonArray)result["blocks"]!;
foreach (var layerProp in layers)
{
if (layerProp.Value is not JsonObject layer)
continue;
blocks.Add(new JsonObject
{
["layer"] = layerProp.Key,
["name"] = JsonString(layer["name"]),
["static_scout_block"] = layer["static_scout_block"]?.DeepClone()
});
}
return result;
}
JsonNode BuildDrawingAssemblyDynamicScoutBlocks()
{
var result = new JsonObject
{
["schema"] = "agent4.drawing_assembly.dynamic_scout_blocks.v1",
["scope"] = "assembly_drawing_only",
["chunks"] = new JsonArray()
};
var index = LoadDrawingAssemblyKnowledgeIndex();
if (index?["layers"] is not JsonObject layers)
return result;
var chunks = (JsonArray)result["chunks"]!;
foreach (var layerProp in layers)
{
if (layerProp.Value is not JsonObject layer || layer["chunks"] is not JsonArray layerChunks)
continue;
foreach (var chunkNode in layerChunks.OfType<JsonObject>())
{
chunks.Add(new JsonObject
{
["layer"] = layerProp.Key,
["layer_name"] = JsonString(layer["name"]),
["chunk_id"] = JsonString(chunkNode["chunk_id"]),
["dynamic_scout_block"] = JsonString(chunkNode["dynamic_scout_block"]),
["retrieval_metadata"] = chunkNode["retrieval_metadata"]?.DeepClone()
});
}
}
return result;
}
JsonNode? LoadDrawingAssemblyKnowledgeIndex()
{
var path = ResolveDrawingAssemblyKnowledgeIndexPath();
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
return null;
try
{
return JsonNode.Parse(File.ReadAllText(path, Encoding.UTF8));
}
catch
{
return null;
}
}
string ResolveDrawingAssemblyKnowledgeIndexPath()
{
var expected = Path.Combine(
_paths.Agent4Root,
"机械制图手册_前200页_mineru",
"drawing_index",
"drawing_assembly_diagnostic_index.json");
if (File.Exists(expected))
return expected;
return Directory.EnumerateFiles(_paths.Agent4Root, "drawing_assembly_diagnostic_index.json", SearchOption.AllDirectories)
.FirstOrDefault(path => path.Replace('\\', '/').Contains("/drawing_index/", StringComparison.OrdinalIgnoreCase)) ?? "";
}
string BuildProbePrompt(DrawingAssemblyViewInput input) =>
$$"""
你是二维装配图查错流程的第一轮“视图探针”。
输入只有两张图片:
1. 学生原始装配图视图图片。
2. 与该视图对应的归属信息图/上色图。
禁止事项:
- 不要假设你看到了 DWG,当前不会给你 DWG。
- 不要做最终判错。
- 不要输出 Markdown 解释。
任务:
只根据两张图片,判断下一步为了充分检查装配图,需要补充哪些结构化事实。必须按三个层次分别提出需求:
1. format_check_needs:格式错误,包括线型、线宽、字号、字体、尺寸样式、剖面线、中心线等。
2. annotation_check_needs:标注错误,包括尺寸漏标/错标、公差漏标/错标、粗糙度漏标/错标、基准和形位公差、技术要求等。
3. representation_check_needs:绘制错误,包括孔/槽/内部结构漏画,局部剖视/剖视表达不足,隐藏线/中心线表达不清等。
请输出严格 JSON:
{
"schema": "agent4.drawing_assembly.ai_view_probe.v1",
"view_id": "{{input.ViewId}}",
"visual_observations": [],
"format_check_needs": [],
"annotation_check_needs": [],
"representation_check_needs": [],
"needed_3d_facts": [],
"needed_dwg_facts": [],
"risk_areas": []
}
""";
string BuildLayeredCandidatePrompt(DrawingAssemblyViewInput input, JsonNode probe, JsonNode facts) =>
$$"""
你是二维装配图查错流程的第二轮“分层候选问题生成器”。
职责边界:
- format_errors 由后端程序化 DWG 几何/实体/线型规则生成,AI 不得生成格式错误候选。
- 本轮 AI 只生成 annotation_errors 和 representation_errors。
- 输出 JSON 中 format_errors 必须保持为空数组 []。
输入:
- 学生原始装配图视图图片。
- 归属信息图/上色图。
- 第一轮视图探针 JSON。
- 后端按需补充的三维和 DWG 结构化事实摘要。
禁止事项:
- 不要读取或引用 DWG 文件本身。
- 不要做最终判错;这里只生成候选问题和检索意图。
- 不要把三个层次混在一起。
必须按以下顺序分别分析:
第一层 format_errors:必须为空,由程序化规则负责。
第二层 annotation_errors:只关注标注错误。
第三层 representation_errors:只关注绘制错误。
每个候选问题必须包含:
- candidate_id
- layer: annotation | representation
- issue_type
- view_location_hint
- visual_evidence
- supplemental_facts_used
- missing_facts
- retrieval_query
- confidence: low | medium | high
公差专项规则:
- 必须逐条读取 supplemental_facts.tolerance_assessment_context.tolerance_candidates 和 unannotated_interface_candidates。
- contact 只能证明存在几何接触,不能单独推出 H7/g6 等具体配合代号。
- 只有“3D 接口及功能 ↔ 名义尺寸/接口身份 ↔ DWG 公差文字 ↔ 知识库规则”可可靠串联时,才生成可判定候选;同直径对应多个接口时必须保留歧义。
- tolerance_assessments 每项必须保留 DWG handle/text、解析后的孔轴公差带及偏差、3D contact/interface ids、功能意图、缺失事实和专用检索词。
- 判断是否需要过盈时,必须读取 connection_assessment:相对运动、载荷/扭矩传递、拆装意图、其他正向固定方式。not_observed 不等于 absent。
粗糙度专项规则:
- 必须逐条读取 supplemental_facts.roughness_assessment_context。
- 粗糙度按参与接触的每一个面分别判断;同一接触的两侧面不得默认使用相同 Ra/Rz。
- 结合接口的运动、承载、定位、密封、压入/拆装和加工面角色,再检索知识得到期望范围。
- “其余”粗糙度不能自动覆盖轴承、滑动、密封、精密定心或过盈配合等关键表面。
第一轮探针:
{{ToPromptJson(probe, 12000)}}
补充事实:
{{ToPromptJson(facts, 24000)}}
公差判定上下文(该块优先于上面的摘要截断结果):
{{ToPromptJson(facts["tolerance_assessment_context"] ?? new JsonObject(), 30000)}}
粗糙度判定上下文:
{{ToPromptJson(facts["roughness_assessment_context"] ?? new JsonObject(), 24000)}}
请输出严格 JSON
{
"schema": "agent4.drawing_assembly.layered_candidate_issues.v1",
"view_id": "{{input.ViewId}}",
"format_errors": [],
"annotation_errors": [],
"tolerance_assessments": [],
"roughness_assessments": [],
"representation_errors": []
}
""";
string BuildFinalPrompt(DrawingAssemblyViewInput input, JsonNode probe, JsonNode facts, JsonNode candidates, JsonNode retrieval) =>
$$"""
你是二维装配图最终查错判定专家。
职责边界:
- 第 1 节“格式错误”由后端程序化规则生成,AI 不得判定线型、线宽、中心线、剖面线、尺寸线、引出线、齿轮分度圆线或螺纹牙底线的格式错误。
- 你只输出以下两节:## 2. 标注错误 和 ## 3. 绘制错误。
- 标注错误包括漏标、错标、取值错误、公差/粗糙度/基准/技术要求错误。
- 绘制错误包括表达不清晰、漏画、剖视/局部视图表达不足、结构关系表达错误。
输入:
- 学生原始装配图视图图片。
- 归属信息图/上色图。
- 第一轮视图探针。
- 后端补充的三维/DWG 结构化事实摘要。
- 第二轮分层候选问题。
- 本地知识库检索结果。
禁止事项:
- 不要声称你直接查看了 DWG;你只能使用图片和结构化事实。
- 不要因为候选存在就判错,必须结合图片、事实和知识库规则。
- 如果证据不足,写“需复核”,不要臆造。
- 公差判定不得仅凭“有接触”推断具体配合;必须引用 tolerance_assessment_context 中的绑定状态和检索结果中的知识 chunk_id。
- 同一直径匹配到多个三维接口、只有单边公差带、接口功能不明确或知识规则不足时,公差结论必须为 needs_review。
- 只有三维接口功能、名义尺寸、DWG 标注和知识规则四方证据闭合,才允许对公差给出 correct 或 incorrect。
- 过盈判定必须证明该连接应固定、需要传递相应载荷/扭矩,并审查其他紧固或止动方式;“未观察到”不得写成“确定不存在”。
- 粗糙度判定必须落实到具体组件和具体 B-rep 面候选,结合接口功能及知识规则;只有全局“其余”标注时仍要单独复核关键配合面。
输出中文 Markdown,只输出以下两节,不要输出总标题,不要输出“## 1. 格式错误”:
## 2. 标注错误
## 3. 绘制错误
每条问题用统一格式:
- 判定:problem | needs_review | pass
- 问题部位:
- 依据:
- 为什么错:
- 修改建议:
对每个公差候选,在“## 2. 标注错误”中另用以下完整格式,不得省略字段:
- 公差候选ID
- 判定:correct | incorrect | missing | needs_review
- DWG图元:handle、原文、名义尺寸、尺寸类型
- 已解析公差:孔公差带、轴公差带、上偏差、下偏差
- 三维接口证据:interface_group_id、contact_id、组件/面编号、接触类型、直径
- 接口功能与预期配合意图:
- 知识依据:必须列出实际检索到的 chunk_id;没有则写“无”
- 证据链与推理:
- 置信度:low | medium | high
- 其他可能解释:
- 缺失事实:
对每个粗糙度候选,同样在“## 2. 标注错误”中输出:
- 粗糙度候选ID
- 判定:correct | incorrect | missing | needs_review
- DWG图元:handle、原文、Ra/Rz 数值、全局或局部范围
- 目标表面:组件、face 编号、接口ID、圆柱/平面、加工角色
- 接口功能和连接方式:
- 期望粗糙度范围及知识 chunk_id:
- 证据链与推理:
- 置信度、其他解释、缺失事实:
第一轮探针:
{{ToPromptJson(probe, 10000)}}
补充事实:
{{ToPromptJson(facts, 18000)}}
公差判定上下文(必须逐条处理,不得被普通事实摘要替代):
{{ToPromptJson(facts["tolerance_assessment_context"] ?? new JsonObject(), 30000)}}
粗糙度判定上下文(必须逐面处理):
{{ToPromptJson(facts["roughness_assessment_context"] ?? new JsonObject(), 24000)}}
候选问题:
{{ToPromptJson(candidates, 18000)}}
检索规则:
{{ToPromptJson(retrieval, 22000)}}
""";
JsonNode BuildDwgFacts(string dir)
{
var obj = new JsonObject
{
["source_dir"] = dir,
["available"] = Directory.Exists(dir)
};
if (!Directory.Exists(dir))
return obj;
foreach (var name in new[]
{
"manifest.json",
"view-regions.json",
"dimensions.json",
"dimensional-tolerances.json",
"geometric-tolerances.json",
"roughness.json",
"datums.json",
"annotations.json",
"geometry.json",
"entities-common.json"
})
{
var path = Path.Combine(dir, name);
obj[Path.GetFileNameWithoutExtension(name).Replace('-', '_')] = SummarizeJsonFile(path, 80);
}
var lineStyleRuleCheck = BuildLineStyleRuleCheck(dir);
obj["line_style_rule_check"] = lineStyleRuleCheck.DeepClone();
WriteJson(Path.Combine(dir, "line-style-rule-check.json"), lineStyleRuleCheck);
return obj;
}
async Task<List<DrawingAssemblyFormatIssue>> BuildFormatIssuesAsync(
string outputDir,
DrawingAssemblyViewInput input,
JsonNode facts)
{
var check = facts["dwg_facts"]?["line_style_rule_check"] as JsonObject;
if (check?["issues"] is not JsonArray rawIssues)
return [];
var selectedRawIssues = rawIssues.OfType<JsonObject>().Take(30).ToList();
var issues = new List<DrawingAssemblyFormatIssue>();
var issueRecords = new List<(DrawingAssemblyFormatIssue Issue, JsonObject RawIssue)>();
var index = 1;
foreach (var issue in selectedRawIssues)
{
if (!TryReadIssueBounds(issue, out var bounds))
continue;
var expanded = ExpandIssueBounds(bounds);
var id = $"format-issue-{index:000}";
var formatIssue = new DrawingAssemblyFormatIssue
{
Id = id,
LineClass = JsonString(issue["line_class"]),
Label = JsonString(issue["line_label"]),
ExpectedStyle = JsonString(issue["expected_style"]),
CurrentStyle = BuildCurrentStyleText(issue),
Reason = JsonString(issue["reason"]),
Confidence = JsonString(issue["confidence"]),
Bounds = new DrawingViewBounds(expanded.MinX, expanded.MinY, expanded.MaxX, expanded.MaxY)
};
issues.Add(formatIssue);
issueRecords.Add((formatIssue, issue));
index++;
}
if (issues.Count == 0)
return issues;
var dwgPath = ResolveDwgPathForEvidence(input);
if (File.Exists(dwgPath))
{
var imageDir = Path.Combine(outputDir, "format_issue_images");
Directory.CreateDirectory(imageDir);
try
{
var viewGroups = issueRecords
.Select(record => new
{
record.Issue,
record.RawIssue,
Region = ResolveEvidenceViewRegion(input.DwgExtractionDir, record.RawIssue)
})
.GroupBy(item => item.Region.Id)
.ToList();
var highlightHandles = CollectHighlightHandles(selectedRawIssues).ToList();
var args = new List<string>
{
"--drawing-path", dwgPath,
"--output-dir", imageDir,
"--padding-mm", "2"
};
if (!string.IsNullOrWhiteSpace(input.ProgId))
args.AddRange(["--prog-id", input.ProgId]);
if (highlightHandles.Count > 0)
args.AddRange(["--highlight-handles", string.Join(",", highlightHandles)]);
foreach (var record in issueRecords.Where(record => IsMissingLineIssue(record.RawIssue)))
args.AddRange(["--highlight-boxes-mm", $"{record.Issue.Id}-missing={record.Issue.Bounds.ToCliString()}"]);
foreach (var group in viewGroups)
{
var region = group.First().Region;
args.AddRange(["--view-bounds-mm", $"{region.Id}-format-issues-highlighted={region.Bounds.ToCliString()}"]);
}
var export = await RunDotnetProjectAsync(
DrawingToolProject("DwgViewPngExporter"),
args,
"export_format_issue_images");
WriteJson(Path.Combine(imageDir, "export-result.json"), export);
foreach (var group in viewGroups)
{
var region = group.First().Region;
var path = Path.Combine(imageDir, $"{region.Id}-format-issues-highlighted.png");
if (!File.Exists(path))
continue;
foreach (var item in group)
{
item.Issue.EvidenceImagePath = path;
item.Issue.EvidenceImageUrl = BuildDrawingEvidenceImageUrl(path);
}
}
}
catch (Exception ex)
{
WriteJson(Path.Combine(imageDir, "export-error.json"), new
{
ok = false,
error = ex.Message,
dwg_path = dwgPath
});
}
}
WriteJson(Path.Combine(outputDir, "format_issues.json"), issues);
return issues;
}
string ResolveDwgPathForEvidence(DrawingAssemblyViewInput input)
{
if (!string.IsNullOrWhiteSpace(input.StudentDwgPath) && File.Exists(input.StudentDwgPath))
return input.StudentDwgPath;
var manifest = Path.Combine(input.DwgExtractionDir, "manifest.json");
if (!File.Exists(manifest))
return "";
try
{
using var doc = JsonDocument.Parse(File.ReadAllText(manifest, Encoding.UTF8));
var path = ReadString(doc.RootElement, "drawing_path");
return string.IsNullOrWhiteSpace(path) ? "" : Path.GetFullPath(path);
}
catch
{
return "";
}
}
static string BuildCurrentStyleText(JsonObject issue)
{
var layer = JsonString(issue["layer"]);
var linetype = JsonString(issue["linetype"]);
var lineweight = issue["lineweight"]?.ToJsonString() ?? "null";
return $"图层={layer},线型={linetype},线宽={lineweight}";
}
static string BuildDrawingEvidenceImageUrl(string path) =>
$"/api/drawing-assembly-diagnostics/image?path={Uri.EscapeDataString(Convert.ToBase64String(Encoding.UTF8.GetBytes(path)))}";
DrawingViewRegion ResolveEvidenceViewRegion(string extractionDir, JsonObject issue)
{
if (TryReadIssueBounds(issue, out var issueBounds))
{
var regions = ReadEvidenceViewRegions(extractionDir);
var containing = regions
.Where(region => BoundsContains(region.Bounds, issueBounds.CenterX, issueBounds.CenterY))
.OrderBy(region => region.Bounds.Width * region.Bounds.Height)
.FirstOrDefault();
if (containing != null)
return containing;
var intersecting = regions
.Select(region => new { Region = region, Area = BoundsIntersectionArea(region.Bounds, issueBounds) })
.Where(item => item.Area > 0)
.OrderByDescending(item => item.Area)
.FirstOrDefault();
if (intersecting != null)
return intersecting.Region;
var expanded = issueBounds.Expanded(20);
return new DrawingViewRegion(
"issue_view_" + SanitizeViewId(JsonString(issue["line_class"])),
"issue_view",
"问题所在视图",
new DrawingViewBounds(expanded.MinX, expanded.MinY, expanded.MaxX, expanded.MaxY));
}
return new DrawingViewRegion("fallback_view", "fallback_view", "视图", new DrawingViewBounds(0, 0, 420, 297));
}
List<DrawingViewRegion> ReadEvidenceViewRegions(string extractionDir)
{
try
{
var regions = ReadStudentViewRegions(extractionDir);
if (regions.Count > 0)
return regions;
}
catch
{
// Fall back below when view-region extraction is unavailable.
}
return [new DrawingViewRegion("fallback_view", "fallback_view", "视图", new DrawingViewBounds(0, 0, 420, 297))];
}
static bool BoundsContains(DrawingViewBounds bounds, double x, double y) =>
x >= bounds.MinX && x <= bounds.MaxX && y >= bounds.MinY && y <= bounds.MaxY;
static double BoundsIntersectionArea(DrawingViewBounds viewBounds, DwgEntityBounds issueBounds)
{
var minX = Math.Max(viewBounds.MinX, issueBounds.MinX);
var minY = Math.Max(viewBounds.MinY, issueBounds.MinY);
var maxX = Math.Min(viewBounds.MaxX, issueBounds.MaxX);
var maxY = Math.Min(viewBounds.MaxY, issueBounds.MaxY);
return maxX > minX && maxY > minY ? (maxX - minX) * (maxY - minY) : 0.0;
}
static IEnumerable<string> CollectHighlightHandles(IEnumerable<JsonObject> issues)
{
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var issue in issues)
{
if (IsMissingLineIssue(issue))
continue;
foreach (var handle in CollectDirectIssueHandles(issue))
{
if (seen.Add(handle))
yield return handle;
}
}
}
static bool IsMissingLineIssue(JsonObject issue) =>
JsonString(issue["issue_type"]).Contains("missing", StringComparison.OrdinalIgnoreCase) ||
string.Equals(JsonString(issue["kind"]), "missing_line_pair", StringComparison.OrdinalIgnoreCase);
static IEnumerable<string> CollectDirectIssueHandles(JsonObject issue)
{
var handle = JsonString(issue["handle"]);
if (!string.IsNullOrWhiteSpace(handle) && !handle.Contains('|') && !handle.StartsWith("pair:", StringComparison.OrdinalIgnoreCase))
yield return handle;
if (issue.TryGetPropertyValue("source_handles", out var sourceHandlesNode) && sourceHandlesNode is JsonArray sourceHandles)
{
foreach (var item in sourceHandles)
{
handle = JsonString(item);
if (!string.IsNullOrWhiteSpace(handle))
yield return handle;
}
}
}
static bool TryReadIssueBounds(JsonObject issue, out DwgEntityBounds bounds)
{
bounds = ReadGeometryBounds(issue["geometry"]);
return bounds.IsValid;
}
static DwgEntityBounds ReadGeometryBounds(JsonNode? geometry)
{
if (geometry is not JsonObject obj)
return DwgEntityBounds.Empty;
var direct = ReadBoundsNode(obj["bbox"]);
if (direct.IsValid)
return direct;
var fromPoints = ReadBoundsFromPoints(obj);
if (fromPoints.IsValid)
return fromPoints;
if (obj["outer_contour_lines"] is JsonArray outerLines)
return UnionBounds(outerLines.Select(ReadGeometryBounds));
return DwgEntityBounds.Empty;
}
static DwgEntityBounds ReadBoundsNode(JsonNode? node)
{
if (node is not JsonObject obj)
return DwgEntityBounds.Empty;
var minX = JsonDouble(obj["min_x"]);
var minY = JsonDouble(obj["min_y"]);
var maxX = JsonDouble(obj["max_x"]);
var maxY = JsonDouble(obj["max_y"]);
return minX != null && minY != null && maxX != null && maxY != null
? new DwgEntityBounds(minX.Value, minY.Value, maxX.Value, maxY.Value)
: DwgEntityBounds.Empty;
}
static DwgEntityBounds ReadBoundsFromPoints(JsonObject obj)
{
var points = new[] { obj["start"], obj["end"], obj["center"] }
.Select(ReadPointNode)
.Where(point => point != null)
.Select(point => point!.Value)
.ToList();
if (points.Count == 0)
return DwgEntityBounds.Empty;
return new DwgEntityBounds(
points.Min(point => point.X),
points.Min(point => point.Y),
points.Max(point => point.X),
points.Max(point => point.Y));
}
static (double X, double Y)? ReadPointNode(JsonNode? node)
{
if (node is not JsonArray arr || arr.Count < 2)
return null;
var x = JsonDouble(arr[0]);
var y = JsonDouble(arr[1]);
return x != null && y != null ? (x.Value, y.Value) : null;
}
static DwgEntityBounds UnionBounds(IEnumerable<DwgEntityBounds> bounds)
{
var valid = bounds.Where(item => item.IsValid).ToList();
return valid.Count == 0
? DwgEntityBounds.Empty
: new DwgEntityBounds(valid.Min(item => item.MinX), valid.Min(item => item.MinY), valid.Max(item => item.MaxX), valid.Max(item => item.MaxY));
}
static DwgEntityBounds ExpandIssueBounds(DwgEntityBounds bounds)
{
var amount = Math.Max(4.0, Math.Max(bounds.MaxX - bounds.MinX, bounds.MaxY - bounds.MinY) * 0.35);
var expanded = bounds.Expanded(amount);
var minSize = 12.0;
var widthPad = Math.Max(0, minSize - (expanded.MaxX - expanded.MinX)) / 2.0;
var heightPad = Math.Max(0, minSize - (expanded.MaxY - expanded.MinY)) / 2.0;
return new DwgEntityBounds(
expanded.MinX - widthPad,
expanded.MinY - heightPad,
expanded.MaxX + widthPad,
expanded.MaxY + heightPad);
}
JsonNode BuildLineStyleRuleCheck(string dir)
{
var result = new JsonObject
{
["schema"] = "agent4.drawing_assembly.line_style_rule_check.v1",
["source_dir"] = dir,
["available"] = Directory.Exists(dir),
["policy"] = "programmatic_rule_check_no_ai",
["notes"] = new JsonArray
{
"Classify drawing lines by deterministic DWG geometry/entity rules before checking line style.",
"Visible outlines are treated as normally heavy; this checker focuses on line classes that should be thin, hidden, or center style.",
"Thread root lines use geometric pair rules, including missing-line detection for probable screw/bolt contours."
},
["classes"] = new JsonArray(),
["issues"] = new JsonArray()
};
if (!Directory.Exists(dir))
return result;
var entities = ReadJsonArray(Path.Combine(dir, "entities-common.json"));
var geometry = ReadJsonArray(Path.Combine(dir, "geometry.json"));
var unknown = ReadJsonArray(Path.Combine(dir, "unknown-entities.json"));
var dimensions = ReadJsonArray(Path.Combine(dir, "dimensions.json"));
var normalizedPath = Path.Combine(dir, "main-view-normalized-geometry.json");
if (!File.Exists(normalizedPath))
normalizedPath = Path.Combine(dir, "normalized-geometry.json");
var normalized = ReadJsonArray(normalizedPath);
var drawableUses = normalized
.Select((item, index) => DwgLineUse.FromElement(item, "normalized_geometry", index))
.Where(item => item != null)
.Cast<DwgLineUse>()
.ToList();
var lines = normalized
.Select((item, index) => DwgLineUse.FromElement(item, "normalized_geometry", index))
.Where(item => item != null && item.Kind.Equals("line", StringComparison.OrdinalIgnoreCase))
.Cast<DwgLineUse>()
.ToList();
var allEntities = entities.Concat(geometry).Concat(unknown).ToList();
var issues = new List<JsonObject>();
var buckets = (JsonArray)result["classes"]!;
var hatchEntities = geometry
.Select((item, index) => DwgLineUse.FromElement(item, "hatch_entity", index, "hatch entity represents section hatching"))
.Where(item => item != null && string.Equals(item.Kind, "hatch", StringComparison.OrdinalIgnoreCase))
.Cast<DwgLineUse>()
.ToList();
var hatchGroups = DetectSectionHatchGroups(lines);
AddLineStyleBucket(
buckets,
issues,
"section_hatch_lines",
"剖面线",
"细实线",
hatchEntities.Concat(DetectSectionHatchLines(hatchGroups)),
CheckThinContinuous,
"几何规则:一组相近、倾斜且平行的重复线按剖面线处理");
AddLineStyleBucket(
buckets,
issues,
"section_or_local_boundary_lines",
"剖视/局部视图分界线",
"细实线",
DetectSectionBoundaryLines(lines, hatchGroups)
.Concat(DetectSectionBoundaryCurves(drawableUses, hatchEntities)),
CheckThinContinuous,
"几何规则:剖面区域外围的包围线、样条曲线和圆弧按剖视或局部视图分界线处理");
AddLineStyleBucket(
buckets,
issues,
"center_axis_lines",
"中心线和轴线",
"细点画线",
DetectCenterAxisLines(lines, drawableUses),
CheckThinCenterLine,
"规则:CAD 明确为中心线/点画线,或线条通过圆、圆弧、孔系中心,或满足回转体轴线的几何条件时,按中心线或轴线处理");
AddLineStyleBucket(
buckets,
issues,
"hidden_lines",
"不可见轮廓线",
"细虚线",
DetectHiddenLines(lines),
CheckThinHiddenLine,
"结构化规则:CAD 图元线型字段显示为 hidden/dashed 的线按不可见轮廓线处理");
AddLineStyleBucket(
buckets,
issues,
"dimension_extension_lines",
"尺寸线、尺寸界线和尺寸标注对象",
"细实线",
dimensions
.Select((item, index) => DwgLineUse.FromElement(item, "dimension_entity", index, "dimension entity object type"))
.Where(item => item != null)
.Cast<DwgLineUse>()
.Concat(DetectDimensionLines(lines)),
CheckThinContinuous,
"实体规则:AutoCAD 尺寸对象及被打散后的尺寸几何线按尺寸线/尺寸界线处理");
AddLineStyleBucket(
buckets,
issues,
"leader_lines",
"引出线和指引线",
"细实线",
allEntities
.Select((item, index) => DwgLineUse.FromElement(item, "leader_entity", index, "leader/callout entity object type"))
.Where(item => item != null && IsLeaderEntity(item))
.Cast<DwgLineUse>()
.Concat(DetectLeaderGeometry(lines)),
CheckThinContinuous,
"实体/几何规则:AutoCAD 引线对象及短折线连接形成的指引线按引出线处理");
AddLineStyleBucket(
buckets,
issues,
"gear_pitch_related_lines",
"齿轮分度圆线/基准线",
"细点画线",
DetectGearPitchLines(drawableUses),
CheckThinCenterLine,
"归属规则:仅在结构化归属或语义字段指向齿轮分度圆/基准几何时判定");
AddLineStyleBucket(
buckets,
issues,
"thread_root_lines",
"螺纹牙底线",
"细实线",
DetectThreadRootLines(lines),
CheckThinContinuous,
"几何规则:两条对称轴向线,间距不超过 30 mm,附近存在更大的同轴外轮廓线对");
AddIssueBucket(
buckets,
issues,
"thread_root_lines_missing",
"缺少螺纹牙底线",
"两条内侧细实线螺纹牙底线",
DetectMissingThreadRootLineIssues(lines),
"几何规则:疑似螺钉/螺栓轴向外轮廓存在,但未找到内侧螺纹牙底线线对");
var issueArray = (JsonArray)result["issues"]!;
foreach (var issue in issues.Take(200))
issueArray.Add(issue.DeepClone());
result["summary"] = new JsonObject
{
["checked_class_count"] = buckets.Count,
["issue_count"] = issues.Count,
["normalized_line_count"] = lines.Count,
["source_scope"] = Path.GetFileName(normalizedPath),
["max_reported_issues"] = 200
};
return result;
}
delegate JsonObject? LineStyleCheck(DwgLineUse line);
static void AddLineStyleBucket(
JsonArray buckets,
List<JsonObject> allIssues,
string lineClass,
string label,
string expectedStyle,
IEnumerable<DwgLineUse> rawEvidence,
LineStyleCheck check,
string note = "")
{
var evidence = DeduplicateLineEvidence(rawEvidence).ToList();
var bucketIssues = new JsonArray();
foreach (var line in evidence)
{
var issue = check(line);
if (issue == null)
continue;
issue["line_class"] = lineClass;
issue["line_label"] = label;
issue["expected_style"] = expectedStyle;
allIssues.Add(issue);
bucketIssues.Add(issue.DeepClone());
}
var sample = new JsonArray();
foreach (var line in evidence.Take(60))
sample.Add(line.ToJson());
buckets.Add(new JsonObject
{
["line_class"] = lineClass,
["label"] = label,
["expected_style"] = expectedStyle,
["checked_count"] = evidence.Count,
["issue_count"] = bucketIssues.Count,
["note"] = note,
["evidence_sample"] = sample,
["issues"] = bucketIssues
});
}
static IEnumerable<DwgLineUse> DeduplicateLineEvidence(IEnumerable<DwgLineUse> lines)
{
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var line in lines)
{
var key = line.StableKey;
if (seen.Add(key))
yield return line;
}
}
static void AddIssueBucket(
JsonArray buckets,
List<JsonObject> allIssues,
string lineClass,
string label,
string expectedStyle,
IEnumerable<JsonObject> rawIssues,
string note = "")
{
var bucketIssues = new JsonArray();
foreach (var rawIssue in rawIssues)
{
rawIssue["line_class"] = lineClass;
rawIssue["line_label"] = label;
rawIssue["expected_style"] = expectedStyle;
allIssues.Add(rawIssue);
bucketIssues.Add(rawIssue.DeepClone());
}
buckets.Add(new JsonObject
{
["line_class"] = lineClass,
["label"] = label,
["expected_style"] = expectedStyle,
["checked_count"] = bucketIssues.Count,
["issue_count"] = bucketIssues.Count,
["note"] = note,
["evidence_sample"] = new JsonArray(),
["issues"] = bucketIssues
});
}
static JsonObject? CheckThinContinuous(DwgLineUse line)
{
if (!UsesHeavyStyle(line))
return null;
return BuildLineStyleIssue(
line,
"heavy_line_used_for_thin_continuous_line",
"该类线条应使用细实线,但当前 CAD 线型/线宽表现为粗线。",
"high");
}
static JsonObject? CheckThinCenterLine(DwgLineUse line)
{
if (UsesExplicitContinuous(line) && UsesHeavyStyle(line))
{
return BuildLineStyleIssue(
line,
"coarse_continuous_line_used_for_center_or_axis_line",
"该类线条应使用细点画线,但当前画成了粗实线。",
"high");
}
if (UsesExplicitContinuous(line))
{
return BuildLineStyleIssue(
line,
"continuous_line_used_for_center_or_axis_line",
"该类线条应使用点画线线型,但当前 CAD 线型为实线。",
"medium");
}
if (UsesHeavyStyle(line))
{
return BuildLineStyleIssue(
line,
"heavy_line_used_for_center_or_axis_line",
"该类线条应为细线,但当前 CAD 线宽表现为粗线。",
"high");
}
return null;
}
static JsonObject? CheckThinHiddenLine(DwgLineUse line)
{
if (UsesExplicitContinuous(line) && UsesHeavyStyle(line))
{
return BuildLineStyleIssue(
line,
"coarse_continuous_line_used_for_hidden_line",
"该类线条应使用细虚线,但当前画成了粗实线。",
"high");
}
if (UsesExplicitContinuous(line))
{
return BuildLineStyleIssue(
line,
"continuous_line_used_for_hidden_line",
"该类线条应使用虚线线型,但当前 CAD 线型为实线。",
"medium");
}
if (UsesHeavyStyle(line))
{
return BuildLineStyleIssue(
line,
"heavy_line_used_for_hidden_line",
"该类线条应为细线,但当前 CAD 线宽表现为粗线。",
"high");
}
return null;
}
static JsonObject BuildLineStyleIssue(DwgLineUse line, string issueType, string reason, string confidence) => new()
{
["issue_type"] = issueType,
["reason"] = reason,
["confidence"] = confidence,
["handle"] = line.Handle,
["source_handles"] = new JsonArray(line.SourceHandles.Select(handle => JsonValue.Create(handle)).ToArray<JsonNode?>()),
["object_name"] = line.ObjectName,
["kind"] = line.Kind,
["source"] = line.Source,
["layer"] = line.Layer,
["linetype"] = line.Linetype,
["lineweight"] = line.Lineweight,
["style_key"] = line.StyleKey,
["ownership_text"] = line.OwnershipText,
["rule_reason"] = line.RuleReason,
["geometry"] = line.GeometryJson()
};
static bool UsesExplicitContinuous(DwgLineUse line) =>
string.Equals(line.Linetype, "Continuous", StringComparison.OrdinalIgnoreCase) ||
string.Equals(line.Linetype, "CONTINUOUS", StringComparison.OrdinalIgnoreCase) ||
string.Equals(line.Linetype, "CONT", StringComparison.OrdinalIgnoreCase);
static bool UsesHeavyStyle(DwgLineUse line)
{
if (line.Lineweight is >= 30)
return true;
var text = line.StyleText;
return ContainsAny(text, "HEAVY", "THICK", "BOLD", "WIDE", "COARSE", "粗", "粗实线", "轮廓线");
}
static bool LooksLikeCenterAxis(DwgLineUse line) =>
ContainsAny(line.StyleText, "CENTER", "CENTRE", "CENTERLINE", "AXIS", "DASHDOT", "CHAIN", "轴线", "中心线", "点画线", "分度圆");
static bool LooksLikeHiddenLine(DwgLineUse line) =>
ContainsAny(line.StyleText, "HIDDEN", "DASH", "DASHED", "INVISIBLE", "不可见", "隐藏", "虚线");
static bool LooksLikeDimensionLine(DwgLineUse line) =>
ContainsAny(line.StyleText, "DIM", "DIMENSION", "EXTENSION", "尺寸", "尺寸线", "尺寸界线", "标注");
static bool LooksLikeLeaderLine(DwgLineUse line) =>
ContainsAny(line.StyleText, "LEADER", "CALLOUT", "NOTE", "指引", "引出", "引线");
static bool LooksLikeGearPitchLine(DwgLineUse line) =>
ContainsAny(line.StyleText, "GEAR", "PITCH", "PCD", "齿轮", "分度圆");
static bool LooksLikeBoundaryMarker(DwgLineUse line) =>
ContainsAny(line.StyleText, "BOUNDARY", "BREAK", "BROKEN", "SECTION", "LOCAL", "WAVE", "ZIGZAG", "分界", "边界", "断裂", "局部", "剖");
static bool ContainsAny(string value, params string[] needles)
{
if (string.IsNullOrWhiteSpace(value))
return false;
return needles.Any(needle => value.Contains(needle, StringComparison.OrdinalIgnoreCase));
}
static bool UsesCenterLinetype(DwgLineUse line) =>
ContainsAny(line.Linetype, "CENTER", "CENTRE", "DASHDOT", "CHAIN");
static bool UsesHiddenLinetype(DwgLineUse line) =>
ContainsAny(line.Linetype, "HIDDEN", "DASH", "DASHED");
static bool IsDimensionEntity(DwgLineUse line) =>
string.Equals(line.Kind, "dimension", StringComparison.OrdinalIgnoreCase) ||
line.ObjectName.Contains("Dimension", StringComparison.OrdinalIgnoreCase);
static bool IsLeaderEntity(DwgLineUse line) =>
string.Equals(line.Kind, "leader", StringComparison.OrdinalIgnoreCase) ||
line.ObjectName.Contains("Leader", StringComparison.OrdinalIgnoreCase) ||
line.ObjectName.Contains("MLeader", StringComparison.OrdinalIgnoreCase);
static bool IsAnnotationEntity(DwgLineUse line) => IsDimensionEntity(line) || IsLeaderEntity(line);
static bool IsStructuredGearOwnership(DwgLineUse line) =>
!string.IsNullOrWhiteSpace(line.OwnershipText) &&
ContainsAny(line.OwnershipText, "GEAR", "齿轮", "分度圆", "PITCH", "PCD");
static bool IsGearPitchRole(DwgLineUse line) =>
IsStructuredGearOwnership(line) &&
(ContainsAny(line.OwnershipText, "PITCH", "PCD", "REFERENCE", "REF", "分度圆", "基准") || UsesCenterLinetype(line));
static List<SectionHatchGroup> DetectSectionHatchGroups(IReadOnlyList<DwgLineUse> lines)
{
var candidates = lines
.Where(line => line.Length > 1e-6)
.Where(line => IsObliqueHatchAngle(line.AngleDeg))
.Where(line => !UsesCenterLinetype(line) && !UsesHiddenLinetype(line) && !IsAnnotationEntity(line))
.ToList();
var groups = new List<SectionHatchGroup>();
var nextId = 1;
foreach (var angleGroup in candidates.GroupBy(line => Math.Round(line.AngleDeg / 5.0) * 5.0))
{
var ordered = angleGroup.OrderBy(line => line.CenterX).ThenBy(line => line.CenterY).ToList();
if (ordered.Count < 5)
continue;
var clusters = ClusterNearbyParallelLines(ordered);
foreach (var cluster in clusters.Where(cluster => cluster.Count >= 5))
{
var offsetSpread = cluster.Max(line => line.NormalOffset) - cluster.Min(line => line.NormalOffset);
var medianLength = Median(cluster.Select(line => line.Length));
if (offsetSpread <= 1e-6 || medianLength <= 1e-6)
continue;
groups.Add(SectionHatchGroup.FromLines($"hatch-group-{nextId++:000}", angleGroup.Key, cluster));
}
}
return groups;
}
static IEnumerable<DwgLineUse> DetectSectionHatchLines(IEnumerable<SectionHatchGroup> groups)
{
foreach (var group in groups)
{
foreach (var line in group.Lines)
yield return line with { RuleReason = $"parallel oblique hatch group {group.Id}, angle={group.AngleBucketDeg:0.#}, count={group.Lines.Count}" };
}
}
static IEnumerable<DwgLineUse> DetectSectionBoundaryLines(IReadOnlyList<DwgLineUse> lines, IReadOnlyList<SectionHatchGroup> hatchGroups)
{
if (hatchGroups.Count == 0)
yield break;
var hatchKeys = hatchGroups.SelectMany(group => group.Lines.Select(line => line.StableKey)).ToHashSet(StringComparer.OrdinalIgnoreCase);
foreach (var line in lines)
{
if (hatchKeys.Contains(line.StableKey))
continue;
foreach (var group in hatchGroups)
{
if (LooksLikeBoundaryMarker(line) && group.Expanded(2.0).Intersects(line.Bounds))
{
yield return line with { RuleReason = $"boundary/break/section marker near detected hatch group {group.Id}" };
break;
}
if (IsHatchEnvelopeBoundary(line, group))
{
yield return line with { RuleReason = $"geometric envelope boundary around detected hatch group {group.Id}" };
break;
}
}
}
}
static IEnumerable<DwgLineUse> DetectSectionBoundaryCurves(
IReadOnlyList<DwgLineUse> uses,
IReadOnlyList<DwgLineUse> hatchEntities)
{
if (hatchEntities.Count == 0)
yield break;
var emitted = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var curve in uses)
{
if (!curve.Bounds.IsValid)
continue;
if (!string.Equals(curve.Kind, "spline", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(curve.Kind, "arc", StringComparison.OrdinalIgnoreCase))
continue;
foreach (var hatch in hatchEntities.Where(hatch => hatch.Bounds.IsValid))
{
if (!curve.Bounds.Expanded(1.0).Intersects(hatch.Bounds))
continue;
if (!CurveTouchesHatchPerimeter(curve.Bounds, hatch.Bounds, 2.0))
continue;
if (emitted.Add(curve.StableKey))
yield return curve with { RuleReason = $"spline/arc boundary curve around hatch entity {hatch.Handle}" };
break;
}
}
}
static bool CurveTouchesHatchPerimeter(DwgEntityBounds curve, DwgEntityBounds hatch, double tolerance)
{
var xTouches = Math.Abs(curve.MinX - hatch.MinX) <= tolerance ||
Math.Abs(curve.MaxX - hatch.MaxX) <= tolerance ||
Math.Abs(curve.MinX - hatch.MaxX) <= tolerance ||
Math.Abs(curve.MaxX - hatch.MinX) <= tolerance;
var yTouches = Math.Abs(curve.MinY - hatch.MinY) <= tolerance ||
Math.Abs(curve.MaxY - hatch.MaxY) <= tolerance ||
Math.Abs(curve.MinY - hatch.MaxY) <= tolerance ||
Math.Abs(curve.MaxY - hatch.MinY) <= tolerance;
return xTouches || yTouches;
}
static bool IsHatchEnvelopeBoundary(DwgLineUse line, SectionHatchGroup group)
{
if (!line.Bounds.IsValid || !group.Bounds.IsValid || line.Length <= 1e-6)
return false;
if (!IsNearlyOrthogonal(line.AngleDeg))
return false;
var bounds = group.Bounds;
var tol = Math.Max(1.0, Median(group.Lines.Select(item => item.Length)) * 0.08);
var horizontal = AngleDifferenceDeg(line.AngleDeg, 0.0) <= 3.0;
if (horizontal)
{
var nearTopOrBottom = Math.Abs(line.CenterY - bounds.MinY) <= tol || Math.Abs(line.CenterY - bounds.MaxY) <= tol;
var overlap = RangeOverlapRatio(line.Bounds.MinX, line.Bounds.MaxX, bounds.MinX, bounds.MaxX);
return nearTopOrBottom && overlap >= 0.35 && line.Length <= Math.Max(8.0, (bounds.MaxX - bounds.MinX) * 1.25);
}
var vertical = AngleDifferenceDeg(line.AngleDeg, 90.0) <= 3.0;
if (vertical)
{
var nearLeftOrRight = Math.Abs(line.CenterX - bounds.MinX) <= tol || Math.Abs(line.CenterX - bounds.MaxX) <= tol;
var overlap = RangeOverlapRatio(line.Bounds.MinY, line.Bounds.MaxY, bounds.MinY, bounds.MaxY);
return nearLeftOrRight && overlap >= 0.35 && line.Length <= Math.Max(8.0, (bounds.MaxY - bounds.MinY) * 1.25);
}
return false;
}
static IEnumerable<DwgLineUse> DetectCenterAxisLines(IReadOnlyList<DwgLineUse> lines, IReadOnlyList<DwgLineUse> uses)
{
var emitted = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var line in lines.Where(UsesCenterLinetype))
{
if (emitted.Add(line.StableKey))
yield return line with { RuleReason = "structured linetype marks center/dash-dot axis" };
}
var circularFeatures = uses
.Where(IsCircularCenterFeature)
.Where(feature => feature.Bounds.IsValid)
.ToList();
foreach (var axis in lines)
{
if (emitted.Contains(axis.StableKey) || axis.Length <= 1e-6 || IsAnnotationEntity(axis) || UsesHiddenLinetype(axis))
continue;
if (PassesThroughCircularFeatureCenter(axis, circularFeatures, out var featureReason))
{
if (emitted.Add(axis.StableKey))
yield return axis with { RuleReason = featureReason };
}
}
var contourCandidates = lines
.Where(line => line.Length > 1e-6 && line.Bounds.IsValid)
.Where(line => !UsesCenterLinetype(line) && !UsesHiddenLinetype(line) && !IsAnnotationEntity(line))
.ToList();
var pairs = BuildCoaxialLinePairs(contourCandidates)
.Where(pair => pair.Spacing > 1.0)
.ToList();
foreach (var axis in lines)
{
if (emitted.Contains(axis.StableKey) || axis.Length <= 1e-6 || IsAnnotationEntity(axis) || UsesHiddenLinetype(axis))
continue;
foreach (var pair in pairs)
{
if (ReferenceEquals(axis, pair.LineA) || ReferenceEquals(axis, pair.LineB))
continue;
if (AngleDifferenceDeg(axis.AngleDeg, pair.AngleDeg) > 2.0)
continue;
if (Math.Abs(NormalOffsetForAngle(axis, pair.AngleDeg) - pair.AxisOffset) > Math.Max(0.8, pair.Spacing * 0.08))
continue;
var range = AxisRangeForAngle(axis, pair.AngleDeg);
var overlap = RangeOverlapRatio(range.Min, range.Max, pair.T0, pair.T1);
var pairLength = Math.Max(1e-9, pair.T1 - pair.T0);
if (overlap < 0.55 || axis.Length < pairLength * 0.65)
continue;
if (!AxisExtendsPastContourRange(range, pair.T0, pair.T1))
continue;
if (!LooksLikeRevolvedAxisContext(axis, pair, circularFeatures))
continue;
if (emitted.Add(axis.StableKey))
yield return axis with { RuleReason = $"geometric revolved-body axis; symmetric contour pair spacing={pair.Spacing:0.###} mm" };
break;
}
}
}
static bool IsCircularCenterFeature(DwgLineUse use) =>
string.Equals(use.Kind, "circle", StringComparison.OrdinalIgnoreCase) &&
use.HasExplicitCenter &&
use.Bounds.IsValid;
static bool PassesThroughCircularFeatureCenter(
DwgLineUse axis,
IReadOnlyList<DwgLineUse> circularFeatures,
out string reason)
{
reason = "";
var hits = circularFeatures
.Where(feature => LinePassesNearPoint(axis, feature.CenterX, feature.CenterY, Math.Max(0.4, Math.Min(feature.Bounds.Width, feature.Bounds.Height) * 0.04)))
.Where(feature => PointProjectionWithinLine(axis, feature.CenterX, feature.CenterY, 2.0))
.Where(feature => AxisCrossesCircularFeature(axis, feature))
.Take(3)
.ToList();
if (hits.Count == 0)
return false;
reason = hits.Count >= 2
? $"center line through {hits.Count} circle/hole centers"
: $"center line through circle/hole center handle={hits[0].Handle}";
return true;
}
static bool AxisCrossesCircularFeature(DwgLineUse axis, DwgLineUse circle)
{
var radius = Math.Max(circle.Bounds.Width, circle.Bounds.Height) / 2.0;
if (radius <= 1e-6)
return false;
var axisRange = AxisRangeForAngle(axis, axis.AngleDeg);
var centerProjection = ProjectPointAlongAngle(circle.CenterX, circle.CenterY, axis.AngleDeg);
var extension = Math.Max(0.8, Math.Min(3.0, radius * 0.12));
return axisRange.Min <= centerProjection - radius - extension &&
axisRange.Max >= centerProjection + radius + extension;
}
static double ProjectPointAlongAngle(double x, double y, double angleDeg)
{
var rad = angleDeg * Math.PI / 180.0;
return x * Math.Cos(rad) + y * Math.Sin(rad);
}
static bool LooksLikeRevolvedAxisContext(DwgLineUse axis, ThreadLinePair pair, IReadOnlyList<DwgLineUse> circularFeatures)
{
if (PassesThroughCircularFeatureCenter(axis, circularFeatures, out _))
return true;
var pairLength = Math.Max(1e-9, pair.T1 - pair.T0);
return pairLength >= Math.Max(20.0, pair.Spacing * 2.5) &&
axis.Length >= pairLength + Math.Min(6.0, Math.Max(1.2, pairLength * 0.08));
}
static bool LinePassesNearPoint(DwgLineUse line, double x, double y, double tolerance)
{
var dx = line.EndX - line.StartX;
var dy = line.EndY - line.StartY;
var len = Math.Sqrt(dx * dx + dy * dy);
if (len <= 1e-9)
return false;
var distance = Math.Abs(dy * x - dx * y + line.EndX * line.StartY - line.EndY * line.StartX) / len;
return distance <= tolerance;
}
static bool PointProjectionWithinLine(DwgLineUse line, double x, double y, double tolerance)
{
var dx = line.EndX - line.StartX;
var dy = line.EndY - line.StartY;
var lenSq = dx * dx + dy * dy;
if (lenSq <= 1e-9)
return false;
var t = ((x - line.StartX) * dx + (y - line.StartY) * dy) / lenSq;
var pad = tolerance / Math.Sqrt(lenSq);
return t >= -pad && t <= 1.0 + pad;
}
static bool AxisExtendsPastContourRange((double Min, double Max) axisRange, double contourMin, double contourMax)
{
var contourLength = Math.Max(1e-9, contourMax - contourMin);
var requiredExtension = Math.Min(3.0, Math.Max(0.6, contourLength * 0.08));
return axisRange.Min <= contourMin - requiredExtension &&
axisRange.Max >= contourMax + requiredExtension;
}
static IEnumerable<DwgLineUse> DetectHiddenLines(IEnumerable<DwgLineUse> lines) =>
lines.Where(UsesHiddenLinetype).Select(line => line with { RuleReason = "structured hidden/dashed linetype field" });
static IEnumerable<DwgLineUse> DetectDimensionLines(IEnumerable<DwgLineUse> lines) =>
lines.Where(IsDimensionEntity).Select(line => line with { RuleReason = "dimension entity object type" });
static IEnumerable<DwgLineUse> DetectLeaderGeometry(IReadOnlyList<DwgLineUse> lines)
{
var emitted = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var candidates = lines
.Where(line => line.Length is >= 2.0 and <= 80.0)
.Where(line => !UsesCenterLinetype(line) && !UsesHiddenLinetype(line) && !IsAnnotationEntity(line))
.ToList();
var degrees = BuildEndpointDegrees(candidates, 0.6);
for (var i = 0; i < candidates.Count; i++)
{
var a = candidates[i];
for (var j = i + 1; j < candidates.Count; j++)
{
var b = candidates[j];
if (!TryGetSharedEndpoint(a, b, 0.6, out var sharedX, out var sharedY))
continue;
var diff = AngleDifferenceDeg(a.AngleDeg, b.AngleDeg);
if (diff < 18.0 || diff > 80.0)
continue;
if (!IsNearlyOrthogonal(a.AngleDeg) && !IsNearlyOrthogonal(b.AngleDeg))
continue;
if (EndpointDegree(degrees, sharedX, sharedY, 0.6) > 2)
continue;
if (!HasFreeEndpoint(a, degrees, sharedX, sharedY, 0.6) || !HasFreeEndpoint(b, degrees, sharedX, sharedY, 0.6))
continue;
var reason = "connected short dogleg leader geometry";
if (emitted.Add(a.StableKey))
yield return a with { RuleReason = reason };
if (emitted.Add(b.StableKey))
yield return b with { RuleReason = reason };
}
}
}
static IEnumerable<DwgLineUse> DetectGearPitchLines(IEnumerable<DwgLineUse> uses) =>
uses.Where(IsGearPitchRole).Select(line => line with { RuleReason = $"structured gear ownership/role: {line.OwnershipText}" });
static IEnumerable<DwgLineUse> DetectThreadRootLines(IReadOnlyList<DwgLineUse> lines)
{
var candidates = lines
.Where(line => line.Length > 1e-6 && line.Bounds.IsValid)
.Where(line => !UsesCenterLinetype(line) && !UsesHiddenLinetype(line) && !IsAnnotationEntity(line))
.ToList();
var pairs = BuildCoaxialLinePairs(candidates).ToList();
var emitted = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var pair in pairs.Where(pair => pair.Spacing <= 30.0 && pair.Spacing > 0.5))
{
if (Math.Min(pair.LineA.Length, pair.LineB.Length) < pair.Spacing * 1.2)
continue;
var contour = pairs
.Where(other => !ReferenceEquals(other, pair))
.Where(other => !PairsShareLine(pair, other))
.Where(other => AngleDifferenceDeg(pair.AngleDeg, other.AngleDeg) <= 2.0)
.Where(other => Math.Abs(pair.AxisOffset - other.AxisOffset) <= Math.Max(0.5, pair.Spacing * 0.05))
.Where(other => other.Spacing > pair.Spacing)
.Where(other => other.Spacing - pair.Spacing >= Math.Max(0.2, pair.Spacing * 0.02))
.Where(other => other.Spacing - pair.Spacing <= Math.Max(4.0, other.Spacing * 0.35))
.Where(other => RangeOverlapRatio(pair.T0, pair.T1, other.T0, other.T1) >= 0.5)
.OrderBy(other => other.Spacing - pair.Spacing)
.FirstOrDefault();
if (contour == null)
continue;
var reason = $"symmetric axial pair spacing={pair.Spacing:0.###} mm <= 30 mm; nearby larger coaxial contour pair spacing={contour.Spacing:0.###} mm";
foreach (var line in new[] { pair.LineA, pair.LineB })
{
if (!emitted.Add(line.StableKey))
continue;
yield return line with { RuleReason = reason };
}
}
}
static IEnumerable<JsonObject> DetectMissingThreadRootLineIssues(IReadOnlyList<DwgLineUse> lines)
{
var candidates = lines
.Where(line => line.Length > 1e-6 && line.Bounds.IsValid)
.Where(line => !UsesCenterLinetype(line) && !UsesHiddenLinetype(line) && !IsAnnotationEntity(line))
.ToList();
var pairs = BuildCoaxialLinePairs(candidates)
.Where(pair => pair.Spacing is > 0.5 and <= 30.0)
.Where(pair => Math.Min(pair.LineA.Length, pair.LineB.Length) >= Math.Max(3.0, pair.Spacing * 1.15))
.OrderByDescending(pair => pair.Spacing)
.ToList();
var emitted = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var contour in pairs)
{
if (HasNearbyOuterThreadContour(contour, pairs))
continue;
if (HasInnerThreadRootPair(contour, pairs))
continue;
var context = DescribeThreadContourEvidence(contour, lines, pairs);
if (context.Score < 2)
continue;
var key = $"{contour.AngleDeg:0.###}:{contour.AxisOffset:0.###}:{contour.Spacing:0.###}:{contour.T0:0.###}:{contour.T1:0.###}";
if (!emitted.Add(key))
continue;
yield return BuildMissingThreadRootLineIssue(contour, context.Confidence, context.Reasons);
}
}
static bool HasNearbyOuterThreadContour(ThreadLinePair inner, IReadOnlyList<ThreadLinePair> pairs) =>
pairs.Any(outer =>
!ReferenceEquals(outer, inner) &&
!PairsShareLine(inner, outer) &&
AngleDifferenceDeg(inner.AngleDeg, outer.AngleDeg) <= 2.0 &&
Math.Abs(inner.AxisOffset - outer.AxisOffset) <= Math.Max(0.5, outer.Spacing * 0.05) &&
outer.Spacing > inner.Spacing &&
outer.Spacing - inner.Spacing >= Math.Max(0.2, inner.Spacing * 0.02) &&
outer.Spacing - inner.Spacing <= Math.Max(4.0, outer.Spacing * 0.35) &&
RangeOverlapRatio(inner.T0, inner.T1, outer.T0, outer.T1) >= 0.45);
static bool HasInnerThreadRootPair(ThreadLinePair contour, IReadOnlyList<ThreadLinePair> pairs) =>
pairs.Any(inner =>
!ReferenceEquals(inner, contour) &&
!PairsShareLine(inner, contour) &&
AngleDifferenceDeg(inner.AngleDeg, contour.AngleDeg) <= 2.0 &&
Math.Abs(inner.AxisOffset - contour.AxisOffset) <= Math.Max(0.5, contour.Spacing * 0.05) &&
inner.Spacing < contour.Spacing &&
contour.Spacing - inner.Spacing >= Math.Max(0.2, inner.Spacing * 0.02) &&
contour.Spacing - inner.Spacing <= Math.Max(4.0, contour.Spacing * 0.35) &&
RangeOverlapRatio(inner.T0, inner.T1, contour.T0, contour.T1) >= 0.45);
static bool PairsShareLine(ThreadLinePair a, ThreadLinePair b)
{
var keys = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
a.LineA.StableKey,
a.LineB.StableKey
};
return keys.Contains(b.LineA.StableKey) || keys.Contains(b.LineB.StableKey);
}
static (int Score, string Confidence, List<string> Reasons) DescribeThreadContourEvidence(
ThreadLinePair contour,
IReadOnlyList<DwgLineUse> lines,
IReadOnlyList<ThreadLinePair> pairs)
{
var score = 0;
var reasons = new List<string>
{
$"symmetric axial contour pair spacing={contour.Spacing:0.###} mm <= 30 mm",
$"parallel overlap range={contour.T1 - contour.T0:0.###} mm"
};
if (Math.Min(contour.LineA.Length, contour.LineB.Length) >= contour.Spacing * 1.8)
{
score++;
reasons.Add("axial contour length is large relative to diameter");
}
if (HasCenterAxisNearPair(contour, lines))
{
score++;
reasons.Add("center/axis line is present on the same pair axis");
}
var transverseCount = CountTransverseContextLines(contour, lines);
if (transverseCount > 0)
{
score++;
reasons.Add($"found {transverseCount} transverse end/shoulder line(s) crossing the contour pair");
}
if (HasAdjacentCoaxialDiameterPair(contour, pairs))
{
score++;
reasons.Add("nearby coaxial diameter pair suggests screw/bolt shank or head transition");
}
var confidence = score >= 3 ? "high" : "medium";
return (score, confidence, reasons);
}
static bool HasCenterAxisNearPair(ThreadLinePair pair, IReadOnlyList<DwgLineUse> lines) =>
lines.Any(line =>
UsesCenterLinetype(line) &&
AngleDifferenceDeg(line.AngleDeg, pair.AngleDeg) <= 2.0 &&
Math.Abs(NormalOffsetForAngle(line, pair.AngleDeg) - pair.AxisOffset) <= Math.Max(0.8, pair.Spacing * 0.08) &&
RangeOverlapRatio(
AxisRangeForAngle(line, pair.AngleDeg).Min,
AxisRangeForAngle(line, pair.AngleDeg).Max,
pair.T0,
pair.T1) >= 0.35);
static int CountTransverseContextLines(ThreadLinePair pair, IReadOnlyList<DwgLineUse> lines)
{
var count = 0;
foreach (var line in lines)
{
if (ReferenceEquals(line, pair.LineA) || ReferenceEquals(line, pair.LineB))
continue;
if (IsAnnotationEntity(line) || UsesHiddenLinetype(line))
continue;
if (Math.Abs(AngleDifferenceDeg(line.AngleDeg, pair.AngleDeg) - 90.0) > 5.0)
continue;
var range = AxisRangeForAngle(line, pair.AngleDeg);
var centerT = (range.Min + range.Max) / 2.0;
var nearEnd = Math.Abs(centerT - pair.T0) <= Math.Max(2.0, pair.Spacing * 0.18) ||
Math.Abs(centerT - pair.T1) <= Math.Max(2.0, pair.Spacing * 0.18);
var withinThread = centerT >= pair.T0 - Math.Max(1.0, pair.Spacing * 0.1) &&
centerT <= pair.T1 + Math.Max(1.0, pair.Spacing * 0.1);
if (!nearEnd && !withinThread)
continue;
var offsetRange = AxisRangeForAngle(line, pair.AngleDeg + 90.0);
var pairOffset0 = pair.AxisOffset - pair.Spacing / 2.0;
var pairOffset1 = pair.AxisOffset + pair.Spacing / 2.0;
var crossesPair = offsetRange.Min <= pairOffset0 + Math.Max(0.8, pair.Spacing * 0.08) &&
offsetRange.Max >= pairOffset1 - Math.Max(0.8, pair.Spacing * 0.08);
if (!crossesPair)
continue;
if (line.Length < pair.Spacing * 0.45 || line.Length > pair.Spacing * 2.5)
continue;
count++;
}
return count;
}
static bool HasAdjacentCoaxialDiameterPair(ThreadLinePair pair, IReadOnlyList<ThreadLinePair> pairs) =>
pairs.Any(other =>
!ReferenceEquals(other, pair) &&
AngleDifferenceDeg(other.AngleDeg, pair.AngleDeg) <= 2.0 &&
Math.Abs(other.AxisOffset - pair.AxisOffset) <= Math.Max(0.8, pair.Spacing * 0.08) &&
Math.Abs(other.Spacing - pair.Spacing) >= Math.Max(1.0, pair.Spacing * 0.08) &&
Math.Abs(other.Spacing - pair.Spacing) <= Math.Max(12.0, pair.Spacing * 0.8) &&
RangesAreAdjacentOrOverlapping(pair.T0, pair.T1, other.T0, other.T1, Math.Max(3.0, pair.Spacing * 0.35)));
static bool RangesAreAdjacentOrOverlapping(double a0, double a1, double b0, double b1, double tolerance) =>
Math.Min(a1, b1) >= Math.Max(a0, b0) - tolerance;
static JsonObject BuildMissingThreadRootLineIssue(ThreadLinePair contour, string confidence, IReadOnlyList<string> reasons)
{
var contextHandles = new[] { contour.LineA.Handle, contour.LineB.Handle }
.Concat(contour.LineA.SourceHandles)
.Concat(contour.LineB.SourceHandles)
.Where(handle => !string.IsNullOrWhiteSpace(handle))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
return new JsonObject
{
["issue_type"] = "missing_thread_root_line_pair",
["reason"] = "已识别到疑似螺钉/螺栓的轴向外轮廓线对,但未找到应绘制的内侧螺纹牙底线线对。",
["confidence"] = confidence,
["handle"] = "",
["source_handles"] = new JsonArray(),
["object_name"] = "inferred thread feature",
["kind"] = "missing_line_pair",
["source"] = "geometry_rule",
["layer"] = string.Join("|", new[] { contour.LineA.Layer, contour.LineB.Layer }.Where(text => !string.IsNullOrWhiteSpace(text)).Distinct(StringComparer.OrdinalIgnoreCase)),
["linetype"] = "",
["lineweight"] = null,
["style_key"] = "",
["rule_reason"] = string.Join("; ", reasons),
["context_handles"] = new JsonArray(contextHandles.Select(handle => JsonValue.Create(handle)).ToArray<JsonNode?>()),
["missing_expected_lines"] = new JsonArray("左右两条内侧螺纹牙底线", "细实线线型"),
["geometry"] = new JsonObject
{
["axis_angle_deg"] = contour.AngleDeg,
["axis_offset"] = contour.AxisOffset,
["outer_contour_spacing"] = contour.Spacing,
["axis_range"] = new JsonObject
{
["t0"] = contour.T0,
["t1"] = contour.T1
},
["outer_contour_lines"] = new JsonArray(contour.LineA.GeometryJson(), contour.LineB.GeometryJson())
}
};
}
static IEnumerable<ThreadLinePair> BuildCoaxialLinePairs(IReadOnlyList<DwgLineUse> lines)
{
foreach (var angleGroup in lines.GroupBy(line => Math.Round(line.AngleDeg / 2.0) * 2.0))
{
var group = angleGroup
.OrderBy(line => NormalOffsetForAngle(line, angleGroup.Key))
.ToList();
for (var i = 0; i < group.Count; i++)
{
var a = group[i];
var offsetA = NormalOffsetForAngle(a, angleGroup.Key);
for (var j = i + 1; j < group.Count; j++)
{
var b = group[j];
if (AngleDifferenceDeg(a.AngleDeg, b.AngleDeg) > 2.0)
continue;
var offsetB = NormalOffsetForAngle(b, angleGroup.Key);
var spacing = Math.Abs(offsetB - offsetA);
if (spacing > 45.0)
break;
if (spacing <= 0.5)
continue;
var rangeA = AxisRangeForAngle(a, angleGroup.Key);
var rangeB = AxisRangeForAngle(b, angleGroup.Key);
var overlap = RangeOverlapRatio(rangeA.Min, rangeA.Max, rangeB.Min, rangeB.Max);
if (overlap < 0.55)
continue;
var lengthRatio = Math.Min(a.Length, b.Length) / Math.Max(a.Length, b.Length);
if (lengthRatio < 0.45)
continue;
yield return new ThreadLinePair(
a,
b,
angleGroup.Key,
(offsetA + offsetB) / 2.0,
spacing,
Math.Max(rangeA.Min, rangeB.Min),
Math.Min(rangeA.Max, rangeB.Max));
}
}
}
}
static (double Min, double Max) AxisRangeForAngle(DwgLineUse line, double angleDeg)
{
var rad = angleDeg * Math.PI / 180.0;
var ux = Math.Cos(rad);
var uy = Math.Sin(rad);
var t0 = line.StartX * ux + line.StartY * uy;
var t1 = line.EndX * ux + line.EndY * uy;
return (Math.Min(t0, t1), Math.Max(t0, t1));
}
static double NormalOffsetForAngle(DwgLineUse line, double angleDeg)
{
var rad = angleDeg * Math.PI / 180.0;
var nx = -Math.Sin(rad);
var ny = Math.Cos(rad);
return line.CenterX * nx + line.CenterY * ny;
}
static double RangeOverlapRatio(double a0, double a1, double b0, double b1)
{
var overlap = Math.Min(a1, b1) - Math.Max(a0, b0);
if (overlap <= 0)
return 0;
var shorter = Math.Min(a1 - a0, b1 - b0);
return shorter <= 1e-9 ? 0 : overlap / shorter;
}
static double AngleDifferenceDeg(double a, double b)
{
var diff = Math.Abs((a % 180.0) - (b % 180.0));
return Math.Min(diff, 180.0 - diff);
}
static bool IsNearlyOrthogonal(double angleDeg) =>
AngleDifferenceDeg(angleDeg, 0.0) <= 3.0 || AngleDifferenceDeg(angleDeg, 90.0) <= 3.0;
static Dictionary<string, int> BuildEndpointDegrees(IReadOnlyList<DwgLineUse> lines, double tolerance)
{
var degrees = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
foreach (var line in lines)
{
AddEndpointDegree(degrees, line.StartX, line.StartY, tolerance);
AddEndpointDegree(degrees, line.EndX, line.EndY, tolerance);
}
return degrees;
}
static void AddEndpointDegree(Dictionary<string, int> degrees, double x, double y, double tolerance)
{
var key = EndpointKey(x, y, tolerance);
degrees[key] = degrees.TryGetValue(key, out var current) ? current + 1 : 1;
}
static int EndpointDegree(Dictionary<string, int> degrees, double x, double y, double tolerance) =>
degrees.TryGetValue(EndpointKey(x, y, tolerance), out var degree) ? degree : 0;
static string EndpointKey(double x, double y, double tolerance) =>
$"{Math.Round(x / tolerance):0}:{Math.Round(y / tolerance):0}";
static bool HasFreeEndpoint(DwgLineUse line, Dictionary<string, int> degrees, double sharedX, double sharedY, double tolerance)
{
var startIsShared = Distance(line.StartX, line.StartY, sharedX, sharedY) <= tolerance;
var freeX = startIsShared ? line.EndX : line.StartX;
var freeY = startIsShared ? line.EndY : line.StartY;
return EndpointDegree(degrees, freeX, freeY, tolerance) <= 1;
}
static bool TryGetSharedEndpoint(DwgLineUse a, DwgLineUse b, double tolerance, out double x, out double y)
{
var pairs = new[]
{
(a.StartX, a.StartY, b.StartX, b.StartY),
(a.StartX, a.StartY, b.EndX, b.EndY),
(a.EndX, a.EndY, b.StartX, b.StartY),
(a.EndX, a.EndY, b.EndX, b.EndY)
};
foreach (var (ax, ay, bx, by) in pairs)
{
if (Distance(ax, ay, bx, by) > tolerance)
continue;
x = (ax + bx) / 2.0;
y = (ay + by) / 2.0;
return true;
}
x = 0;
y = 0;
return false;
}
static double Distance(double ax, double ay, double bx, double by)
{
var dx = ax - bx;
var dy = ay - by;
return Math.Sqrt(dx * dx + dy * dy);
}
static bool IsObliqueHatchAngle(double angle)
{
var a = angle % 180.0;
if (a < 0)
a += 180.0;
return Math.Abs(a - 0.0) > 10.0 &&
Math.Abs(a - 90.0) > 10.0 &&
Math.Abs(a - 180.0) > 10.0;
}
static List<List<DwgLineUse>> ClusterNearbyParallelLines(IReadOnlyList<DwgLineUse> lines)
{
var clusters = new List<List<DwgLineUse>>();
foreach (var line in lines)
{
var target = clusters.FirstOrDefault(cluster => cluster.Any(item => item.Bounds.Expanded(10.0).Intersects(line.Bounds)));
if (target == null)
{
target = new List<DwgLineUse>();
clusters.Add(target);
}
target.Add(line);
}
return clusters;
}
static double Median(IEnumerable<double> values)
{
var sorted = values.OrderBy(value => value).ToList();
if (sorted.Count == 0)
return 0;
var mid = sorted.Count / 2;
return sorted.Count % 2 == 1 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2.0;
}
static List<JsonElement> ReadJsonArray(string path)
{
if (!File.Exists(path))
return [];
try
{
using var doc = JsonDocument.Parse(File.ReadAllText(path, Encoding.UTF8));
return doc.RootElement.ValueKind == JsonValueKind.Array
? doc.RootElement.EnumerateArray().Select(item => item.Clone()).ToList()
: [];
}
catch
{
return [];
}
}
JsonNode BuildMechanicalFacts(string dir)
{
var obj = new JsonObject
{
["source_dir"] = dir,
["available"] = Directory.Exists(dir)
};
if (!Directory.Exists(dir))
return obj;
foreach (var relative in new[]
{
"assembly_structure_summary.json",
"functional_blocks.json",
"part_function_profiles.json",
"component_work_queue.json",
"mechanical_semantic_graph.json"
})
{
var path = ResolveMechanicalContextFile(dir, relative);
obj[Path.GetFileNameWithoutExtension(relative)] = SummarizeJsonFile(path, 60);
}
var componentEvidence = Directory.EnumerateFiles(dir, "manifest.json", SearchOption.AllDirectories)
.Where(path => path.Replace('\\', '/').Contains("/component_evidence/", StringComparison.OrdinalIgnoreCase))
.Take(20)
.Select(path => new JsonObject
{
["path"] = path,
["summary"] = SummarizeJsonFile(path, 20)
})
.ToArray();
obj["component_evidence_manifests"] = new JsonArray(componentEvidence);
return obj;
}
JsonNode BuildToleranceAssessmentContext(string dwgDir, string mechanicalDir, DrawingOwnershipData ownershipData)
{
var annotations = ReadParsedToleranceAnnotations(dwgDir);
var drawingGeometry = ReadDrawingGeometryEntities(dwgDir);
var interfaceContext = BuildExternalCylindricalInterfaces(mechanicalDir);
var groups = interfaceContext["interface_groups"] as JsonArray ?? [];
var candidates = new JsonArray();
var matchedGroupIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var annotation in annotations.OfType<JsonObject>())
{
annotation["drawing_geometry_binding"] = BuildDimensionDrawingGeometryBinding(annotation, drawingGeometry, ownershipData.Samples);
var ownershipBinding = BuildAnnotationOwnershipBinding(annotation, ownershipData.Samples);
annotation["ownership_binding"] = ownershipBinding;
var boundaryPairs = ReadDrawingBoundaryComponentPairs(annotation);
var interfaceDiameter = JsonDouble(annotation["interface_diameter_mm"]);
var scoredGroups = new List<(JsonObject Group, double Score, int OwnerMatches, int BoundaryPairMatches)>();
if (interfaceDiameter is > 0)
{
var tolerance = Math.Max(0.05, interfaceDiameter.Value * 0.001);
foreach (var group in groups.OfType<JsonObject>())
{
var groupDiameter = JsonDouble(group["diameter_mm"]);
if (groupDiameter == null || Math.Abs(groupDiameter.Value - interfaceDiameter.Value) > tolerance)
continue;
var ownerMatches = CountOwnershipMatches(ownershipBinding, group);
var boundaryPairMatches = CountBoundaryPairMatches(boundaryPairs, group);
var score = 0.5 + ownerMatches * 0.25 + Math.Min(boundaryPairMatches, 1) * 0.35;
scoredGroups.Add((group, score, ownerMatches, boundaryPairMatches));
}
}
var bestScore = scoredGroups.Select(item => item.Score).DefaultIfEmpty(0).Max();
var bestGroups = scoredGroups
.Where(item => Math.Abs(item.Score - bestScore) < 0.0001)
.OrderBy(item => JsonString(item.Group["interface_group_id"]), StringComparer.OrdinalIgnoreCase)
.ToList();
var matchedGroups = new JsonArray();
foreach (var item in scoredGroups.OrderByDescending(item => item.Score))
{
var clone = item.Group.DeepClone() as JsonObject ?? new JsonObject();
clone["binding_score"] = item.Score;
clone["ownership_component_match_count"] = item.OwnerMatches;
clone["ownership_matched_sides"] = BuildOwnershipMatchedSides(ownershipBinding, item.Group);
clone["boundary_component_pair_match_count"] = item.BoundaryPairMatches;
clone["boundary_component_pair_evidence"] = BuildBoundaryPairEvidence(boundaryPairs, item.Group);
clone["brep_boundary_pair_match_status"] = item.BoundaryPairMatches > 0
? "boundary_pair_supports_this_brep_interface_candidate"
: "not_supported_by_boundary_pair";
clone["is_best_binding_candidate"] = bestGroups.Any(best => ReferenceEquals(best.Group, item.Group));
matchedGroups.Add(clone);
}
foreach (var item in bestGroups)
{
var groupId = JsonString(item.Group["interface_group_id"]);
if (!string.IsNullOrWhiteSpace(groupId))
matchedGroupIds.Add(groupId);
}
var bindingStatus = bestGroups.Count switch
{
0 => "no_3d_diameter_match",
1 when bestGroups[0].OwnerMatches > 0 => "matched_by_diameter_and_drawing_ownership",
1 => "matched_one_interface_group_by_diameter_only",
_ when bestGroups[0].OwnerMatches > 0 => "ambiguous_after_diameter_and_ownership_match",
_ => "ambiguous_multiple_interface_groups"
};
candidates.Add(new JsonObject
{
["candidate_id"] = $"tolerance_{JsonString(annotation["handle"])}",
["dwg_annotation"] = annotation.DeepClone(),
["related_3d_interfaces"] = matchedGroups,
["drawing_boundary_to_3d_interface_matches"] = BuildDrawingBoundaryTo3dInterfaceMatches(boundaryPairs, groups, interfaceDiameter),
["binding_status"] = bindingStatus,
["binding_basis"] = "nominal diameter plus dimension definition-point/component ownership plus boundary-line component-pair evidence; B-rep face ids and location signatures are retained for final interface identity checking",
["expected_fit_intent"] = "must_be_inferred_from_bound_interface_function_and_knowledge_not_from_contact_alone",
["allowed_verdicts"] = new JsonArray("correct", "incorrect", "missing", "needs_review"),
["preliminary_verdict"] = bindingStatus == "matched_one_interface_group" ? "needs_review" : "needs_review"
});
}
var unannotated = new JsonArray();
foreach (var group in groups.OfType<JsonObject>())
{
var groupId = JsonString(group["interface_group_id"]);
if (matchedGroupIds.Contains(groupId))
continue;
unannotated.Add(new JsonObject
{
["candidate_id"] = $"missing_tolerance_{groupId}",
["related_3d_interface"] = group.DeepClone(),
["candidate_kind"] = "cylindrical_interface_without_nominally_matching_dwg_tolerance",
["preliminary_verdict"] = "needs_review",
["missing_rule"] = "Do not call this missing until interface function and drawing-scope knowledge prove that a fit/tolerance annotation is required."
});
}
return new JsonObject
{
["schema"] = "agent4.drawing_assembly.tolerance_assessment_context.v1",
["dwg_source_dir"] = dwgDir,
["mechanical_source_dir"] = mechanicalDir,
["policy"] = new JsonArray
{
"Never infer an exact fit designation from contact geometry alone.",
"A correct/incorrect verdict requires a reliable 3D interface/function, nominal-size, DWG-annotation and knowledge-rule evidence chain.",
"Keep multiple same-diameter interface matches explicit and return needs_review unless other evidence resolves ownership.",
"Internal contacts excluded by the source 3D external-interface report must not be reintroduced."
},
["drawing_ownership_source"] = ownershipData.Summary.DeepClone(),
["parsed_dwg_tolerance_annotations"] = annotations,
["cylindrical_3d_interfaces"] = interfaceContext,
["tolerance_candidates"] = candidates,
["unannotated_interface_candidates"] = unannotated
};
}
JsonObject BuildExternalCylindricalInterfaces(string dir)
{
var result = new JsonObject
{
["available"] = false,
["source_report"] = "",
["contact_count"] = 0,
["interface_group_count"] = 0,
["interface_groups"] = new JsonArray()
};
if (!Directory.Exists(dir))
return result;
var reportPath = ResolveSectionReportPath(dir);
if (string.IsNullOrWhiteSpace(reportPath))
return result;
try
{
using var doc = JsonDocument.Parse(File.ReadAllText(reportPath));
if (!TryGetPropertyIgnoreCase(doc.RootElement, "AssemblyFaceContacts", out var contactsElement) ||
contactsElement.ValueKind != JsonValueKind.Array)
return result;
var contacts = new List<JsonObject>();
foreach (var contact in contactsElement.EnumerateArray())
{
var kindA = ReadJsonString(contact, "FaceKindA");
var kindB = ReadJsonString(contact, "FaceKindB");
if (!string.Equals(kindA, "cylinder", StringComparison.OrdinalIgnoreCase) ||
!string.Equals(kindB, "cylinder", StringComparison.OrdinalIgnoreCase))
continue;
var radiusA = ReadJsonDouble(contact, "RadiusAmm");
var radiusB = ReadJsonDouble(contact, "RadiusBmm");
var radius = radiusA is > 0 ? radiusA : radiusB;
if (radius is not > 0)
continue;
contacts.Add(new JsonObject
{
["contact_id"] = ReadJsonString(contact, "Id"),
["contact_kind"] = ReadJsonString(contact, "ContactKind"),
["component_a"] = ReadJsonString(contact, "ComponentA"),
["component_b"] = ReadJsonString(contact, "ComponentB"),
["face_a"] = ReadJsonInt(contact, "FaceA"),
["face_b"] = ReadJsonInt(contact, "FaceB"),
["surface_process_role_a"] = ReadJsonString(contact, "SurfaceProcessRoleA"),
["surface_process_role_b"] = ReadJsonString(contact, "SurfaceProcessRoleB"),
["radius_mm"] = radius,
["diameter_mm"] = radius * 2,
["gap_mm"] = ReadJsonDouble(contact, "GapMm"),
["confidence"] = ReadJsonDouble(contact, "Confidence"),
["verification_status"] = ReadJsonString(contact, "VerificationStatus"),
["evidence"] = ReadJsonString(contact, "Evidence"),
["center_a_mm"] = ReadJsonNode(contact, "CenterA"),
["center_b_mm"] = ReadJsonNode(contact, "CenterB"),
["axis_a"] = ReadJsonNode(contact, "AxisA"),
["axis_b"] = ReadJsonNode(contact, "AxisB"),
["bbox_a_mm"] = ReadJsonNode(contact, "BBoxA"),
["bbox_b_mm"] = ReadJsonNode(contact, "BBoxB")
});
}
var semanticUnits = ReadInterfaceSemanticUnits(dir);
var groups = contacts
.GroupBy(contact => string.Join("|",
JsonString(contact["component_a"]),
JsonString(contact["component_b"]),
(JsonDouble(contact["diameter_mm"]) ?? 0).ToString("0.###", System.Globalization.CultureInfo.InvariantCulture)),
StringComparer.OrdinalIgnoreCase)
.OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase)
.ToList();
var groupNodes = new JsonArray();
for (var index = 0; index < groups.Count; index++)
{
var items = groups[index].ToList();
var first = items[0];
var contactIds = items.Select(item => JsonString(item["contact_id"])).ToHashSet(StringComparer.OrdinalIgnoreCase);
var functions = MatchInterfaceFunctions(
semanticUnits,
JsonString(first["component_a"]),
JsonString(first["component_b"]),
contactIds);
var connectionAssessment = BuildConnectionAssessment(
dir,
JsonString(first["component_a"]),
JsonString(first["component_b"]),
functions);
groupNodes.Add(new JsonObject
{
["interface_group_id"] = $"cyl_interface_{index + 1:000}",
["component_a"] = first["component_a"]?.DeepClone(),
["component_b"] = first["component_b"]?.DeepClone(),
["diameter_mm"] = first["diameter_mm"]?.DeepClone(),
["radius_mm"] = first["radius_mm"]?.DeepClone(),
["contact_record_count"] = items.Count,
["contact_ids"] = new JsonArray(items.Select(item => (JsonNode?)JsonValue.Create(JsonString(item["contact_id"]))).ToArray()),
["face_pairs"] = new JsonArray(items.Select(item => (JsonNode?)new JsonObject
{
["face_a"] = item["face_a"]?.DeepClone(),
["face_b"] = item["face_b"]?.DeepClone(),
["center_a_mm"] = item["center_a_mm"]?.DeepClone(),
["center_b_mm"] = item["center_b_mm"]?.DeepClone(),
["axis_a"] = item["axis_a"]?.DeepClone(),
["axis_b"] = item["axis_b"]?.DeepClone(),
["bbox_a_mm"] = item["bbox_a_mm"]?.DeepClone(),
["bbox_b_mm"] = item["bbox_b_mm"]?.DeepClone(),
["surface_process_role_a"] = item["surface_process_role_a"]?.DeepClone(),
["surface_process_role_b"] = item["surface_process_role_b"]?.DeepClone()
}).ToArray()),
["max_contact_confidence"] = items.Select(item => JsonDouble(item["confidence"]) ?? 0).DefaultIfEmpty().Max(),
["interface_function_descriptions"] = functions,
["function_binding_status"] = functions.Count > 0 ? "available" : "not_found",
["connection_assessment"] = connectionAssessment,
["drawing_requirement_intent"] = new JsonObject
{
["fit_intent"] = "derive_from_interface_function_connection_assessment_and_knowledge",
["surface_finish_intent"] = "derive_per_participating_face_from_motion_load_sealing_and_assembly_method",
["exact_value_policy"] = "do_not_invent_exact_fit_or_roughness_without_retrieved_rule"
}
});
}
result["available"] = true;
result["source_report"] = reportPath;
result["contact_count"] = contacts.Count;
result["interface_group_count"] = groupNodes.Count;
result["interface_groups"] = groupNodes;
result["source_scope_note"] = "Uses the selected report as-is; source-side external/internal filtering is preserved.";
return result;
}
catch (Exception ex)
{
result["error"] = ex.Message;
return result;
}
}
JsonObject BuildAssemblyFunctionContext(string dir)
{
var cylindrical = BuildExternalCylindricalInterfaces(dir);
var planar = BuildPlanarInterfaceProfiles(dir);
return new JsonObject
{
["schema"] = "agent4.assembly_function_context.v1",
["source_dir"] = dir,
["layer_separation_policy"] = new JsonArray
{
"component_function_profiles describe whole-component purpose only.",
"interface_function_profiles describe one physical contact/interface location only.",
"connection_assessments decide how the pair is retained or allowed to move and must inspect the whole assembly.",
"drawing_requirement_intent is downstream input for tolerance and roughness knowledge retrieval, not an exact value guessed from geometry."
},
["component_function_profiles"] = ReadComponentFunctionProfiles(dir),
["interface_function_profiles"] = new JsonObject
{
["cylindrical"] = cylindrical["interface_groups"]?.DeepClone() ?? new JsonArray(),
["planar"] = planar
},
["connection_analysis_policy"] = new JsonArray
{
"Geometric contact alone does not determine clearance, transition or interference fit.",
"Check intended relative motion, load/torque transfer, axial and circumferential retention, disassembly intent and all alternative positive-retention mechanisms.",
"not_observed is not equivalent to absent; unresolved whole-assembly evidence requires needs_review."
}
};
}
JsonArray ReadComponentFunctionProfiles(string dir)
{
var result = new JsonArray();
var path = ResolveMechanicalContextFile(dir, "component_function_introductions.json");
if (!File.Exists(path))
return result;
try
{
using var doc = JsonDocument.Parse(File.ReadAllText(path));
if (!TryGetPropertyIgnoreCase(doc.RootElement, "components", out var components) || components.ValueKind != JsonValueKind.Array)
return result;
foreach (var component in components.EnumerateArray())
{
var selected = TryGetPropertyIgnoreCase(component, "selected_profile", out var profile) ? profile : default;
result.Add(new JsonObject
{
["component_id"] = ReadJsonString(component, "component_id"),
["instance_name"] = ReadJsonString(component, "instance_name"),
["whole_component_function"] = ReadJsonString(component, "function_introduction"),
["assembly_function"] = selected.ValueKind == JsonValueKind.Object ? ReadJsonString(selected, "assembly_function") : "",
["responsibilities"] = selected.ValueKind == JsonValueKind.Object ? ReadJsonNode(selected, "responsibilities") : null,
["related_interface_ids"] = selected.ValueKind == JsonValueKind.Object ? ReadJsonNode(selected, "interface_relations") : null,
["confidence"] = ReadJsonString(component, "confidence"),
["scope_rule"] = "whole_component_only_do_not_use_as_a_specific_contact_function"
});
}
}
catch (Exception ex)
{
result.Add(new JsonObject { ["source_path"] = path, ["error"] = ex.Message });
}
return result;
}
JsonArray BuildPlanarInterfaceProfiles(string dir)
{
var result = new JsonArray();
var reportPath = ResolveSectionReportPath(dir);
if (string.IsNullOrWhiteSpace(reportPath))
return result;
try
{
using var doc = JsonDocument.Parse(File.ReadAllText(reportPath));
if (!TryGetPropertyIgnoreCase(doc.RootElement, "AssemblyFaceContacts", out var contacts) || contacts.ValueKind != JsonValueKind.Array)
return result;
var semanticUnits = ReadInterfaceSemanticUnits(dir);
var index = 0;
foreach (var contact in contacts.EnumerateArray())
{
if (!string.Equals(ReadJsonString(contact, "FaceKindA"), "plane", StringComparison.OrdinalIgnoreCase) ||
!string.Equals(ReadJsonString(contact, "FaceKindB"), "plane", StringComparison.OrdinalIgnoreCase))
continue;
index++;
var contactId = ReadJsonString(contact, "Id");
var componentA = ReadJsonString(contact, "ComponentA");
var componentB = ReadJsonString(contact, "ComponentB");
var functions = MatchInterfaceFunctions(semanticUnits, componentA, componentB, new HashSet<string>([contactId], StringComparer.OrdinalIgnoreCase));
result.Add(new JsonObject
{
["interface_group_id"] = $"planar_interface_{index:000}",
["contact_ids"] = new JsonArray(contactId),
["component_a"] = componentA,
["component_b"] = componentB,
["face_pairs"] = new JsonArray(new JsonObject
{
["face_a"] = ReadJsonInt(contact, "FaceA"),
["face_b"] = ReadJsonInt(contact, "FaceB"),
["center_a_mm"] = ReadJsonNode(contact, "CenterA"),
["center_b_mm"] = ReadJsonNode(contact, "CenterB"),
["bbox_a_mm"] = ReadJsonNode(contact, "BBoxA"),
["bbox_b_mm"] = ReadJsonNode(contact, "BBoxB"),
["surface_process_role_a"] = ReadJsonString(contact, "SurfaceProcessRoleA"),
["surface_process_role_b"] = ReadJsonString(contact, "SurfaceProcessRoleB")
}),
["gap_mm"] = ReadJsonDouble(contact, "GapMm"),
["overlap_measure_mm2"] = ReadJsonDouble(contact, "OverlapMeasureMm2"),
["interface_function_descriptions"] = functions,
["connection_assessment"] = BuildConnectionAssessment(dir, componentA, componentB, functions),
["drawing_requirement_intent"] = new JsonObject
{
["surface_finish_intent"] = "derive_for_each_face_from_locating_load_transfer_sealing_and_motion_function",
["exact_value_policy"] = "knowledge_required"
}
});
}
}
catch (Exception ex)
{
result.Add(new JsonObject { ["source_report"] = reportPath, ["error"] = ex.Message });
}
return result;
}
JsonObject BuildConnectionAssessment(string dir, string componentA, string componentB, JsonArray functions)
{
var functionText = functions.ToJsonString();
var relativeMotion = ContainsAny(functionText, "旋转", "转动", "滑动", "导向", "rotat", "slid", "guid")
? "motion_expected_candidate"
: ContainsAny(functionText, "固定", "刚性", "锁紧", "传递扭矩", "fixed", "rigid", "fasten")
? "fixed_relation_candidate"
: "unknown";
var retentionKeywordObserved = ContainsAny(functionText, "螺钉", "螺栓", "键连接", "花键", "挡圈", "卡簧", "夹紧", "焊接", "胶接", "screw", "bolt", "keyed", "spline", "retaining ring", "circlip", "clamp", "weld", "adhesive");
var interferenceKeywordObserved = ContainsAny(functionText, "过盈", "压入", "press fit", "interference");
var pairMates = ReadPairMateEvidence(dir, componentA, componentB);
var positiveRetentionEvidence = ReadPositiveRetentionEvidence(dir, componentA, componentB);
retentionKeywordObserved |= positiveRetentionEvidence.Count > 0;
var interferenceStatus = interferenceKeywordObserved
? "supported_candidate_requires_standard_check"
: relativeMotion == "motion_expected_candidate"
? "generally_not_expected_but_requires_function_check"
: retentionKeywordObserved
? "not_required_by_current_evidence_other_retention_observed"
: "needs_review_other_positive_retention_not_observed";
return new JsonObject
{
["component_a"] = componentA,
["component_b"] = componentB,
["relative_motion_intent"] = relativeMotion,
["solidworks_constraint_evidence"] = pairMates,
["constraint_evidence_note"] = "SolidWorks mates constrain the CAD model but are not themselves physical fasteners.",
["positive_retention_evidence"] = positiveRetentionEvidence,
["other_positive_retention_status"] = retentionKeywordObserved ? "observed_in_semantic_evidence" : "not_observed_not_proven_absent",
["interference_fit_necessity"] = interferenceStatus,
["required_whole_assembly_checks"] = new JsonArray
{
"Is relative rotation or sliding required?",
"What carries radial load, axial load and torque?",
"Are screws, bolts, pins, keys, splines, shoulders, retaining rings, clamps, welds, adhesives or intermediate connected parts present?",
"Must the interface be disassembled for assembly or maintenance?",
"If no other positive retention is confirmed and the relation must remain fixed, is interference fit required by the retrieved design standard?"
},
["confidence"] = interferenceKeywordObserved || retentionKeywordObserved ? "medium" : "low",
["missing_facts"] = retentionKeywordObserved
? new JsonArray("quantified loads and exact fit standard")
: new JsonArray("confirmed physical retention mechanism", "relative motion requirement", "load and torque path", "disassembly intent")
};
}
JsonArray ReadPairMateEvidence(string dir, string componentA, string componentB)
{
var result = new JsonArray();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var reportPath in EnumerateSectionReportsNear(dir))
{
try
{
using var doc = JsonDocument.Parse(File.ReadAllText(reportPath));
if (!TryGetPropertyIgnoreCase(doc.RootElement, "AssemblyMates", out var mates) || mates.ValueKind != JsonValueKind.Array)
continue;
foreach (var mate in mates.EnumerateArray())
{
var mateA = ReadJsonString(mate, "ComponentA");
var mateB = ReadJsonString(mate, "ComponentB");
if (!(ComponentNamesMatch(componentA, mateA) && ComponentNamesMatch(componentB, mateB)) &&
!(ComponentNamesMatch(componentA, mateB) && ComponentNamesMatch(componentB, mateA)))
continue;
var id = ReadJsonString(mate, "Id");
if (!seen.Add(id))
continue;
result.Add(new JsonObject
{
["mate_id"] = id,
["mate_name"] = ReadJsonString(mate, "MateName"),
["mate_type"] = ReadJsonString(mate, "MateTypeName"),
["source_report"] = reportPath
});
}
}
catch
{
// Continue with other reports; stale reports are supplemental evidence only.
}
}
return result;
}
JsonArray ReadPositiveRetentionEvidence(string dir, string componentA, string componentB)
{
var result = new JsonArray();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var reportPath in EnumerateSectionReportsNear(dir))
{
try
{
using var doc = JsonDocument.Parse(File.ReadAllText(reportPath));
var edges = new List<(string A, string B, string RelationId)>();
if (TryGetPropertyIgnoreCase(doc.RootElement, "AssemblyFaceContacts", out var contacts) && contacts.ValueKind == JsonValueKind.Array)
{
edges.AddRange(contacts.EnumerateArray().Select(contact => (
ReadJsonString(contact, "ComponentA"),
ReadJsonString(contact, "ComponentB"),
ReadJsonString(contact, "Id"))));
}
if (TryGetPropertyIgnoreCase(doc.RootElement, "AssemblyMates", out var mates) && mates.ValueKind == JsonValueKind.Array)
{
edges.AddRange(mates.EnumerateArray().Select(mate => (
ReadJsonString(mate, "ComponentA"),
ReadJsonString(mate, "ComponentB"),
ReadJsonString(mate, "Id"))));
}
var componentNames = edges.SelectMany(edge => new[] { edge.A, edge.B })
.Where(name => ContainsAny(name, "螺钉", "螺栓", "销钉", "键", "花键", "挡圈", "卡簧", "screw", "bolt", "dowel", "pin", "key", "spline", "retaining ring", "circlip"))
.Distinct(StringComparer.OrdinalIgnoreCase);
foreach (var fastener in componentNames)
{
var linksA = edges.Where(edge => (ComponentNamesMatch(edge.A, fastener) && ComponentNamesMatch(edge.B, componentA)) ||
(ComponentNamesMatch(edge.B, fastener) && ComponentNamesMatch(edge.A, componentA))).ToList();
var linksB = edges.Where(edge => (ComponentNamesMatch(edge.A, fastener) && ComponentNamesMatch(edge.B, componentB)) ||
(ComponentNamesMatch(edge.B, fastener) && ComponentNamesMatch(edge.A, componentB))).ToList();
if (linksA.Count == 0 || linksB.Count == 0)
continue;
var key = NormalizeComponentName(fastener);
if (!seen.Add(key))
continue;
result.Add(new JsonObject
{
["mechanism_component"] = fastener,
["mechanism_kind"] = "fastener_or_positive_retention_component_candidate",
["links_to_component_a"] = new JsonArray(linksA.Select(edge => (JsonNode?)JsonValue.Create(edge.RelationId)).ToArray()),
["links_to_component_b"] = new JsonArray(linksB.Select(edge => (JsonNode?)JsonValue.Create(edge.RelationId)).ToArray()),
["source_report"] = reportPath,
["confidence"] = "medium"
});
}
}
catch
{
// Positive-retention evidence is supplemental; continue scanning other reports.
}
}
return result;
}
DrawingOwnershipData BuildDrawingOwnershipContext(string dir)
{
var samples = new List<DrawingOwnershipSample>();
var summary = new JsonObject
{
["available"] = false,
["source_path"] = "",
["sample_count"] = 0,
["component_count"] = 0,
["components"] = new JsonArray()
};
if (!Directory.Exists(dir))
return new DrawingOwnershipData(summary, samples);
var path = Directory.EnumerateFiles(dir, "*.jsonl", SearchOption.AllDirectories)
.Where(file => Path.GetFileName(file).Contains("ownership", StringComparison.OrdinalIgnoreCase) ||
Path.GetFileName(file).Contains("hover-grid", StringComparison.OrdinalIgnoreCase) ||
Path.GetFileName(file).Contains("grid-ownership", StringComparison.OrdinalIgnoreCase))
.OrderByDescending(file => Path.GetFileName(file).Equals("ownership-grid.jsonl", StringComparison.OrdinalIgnoreCase))
.ThenByDescending(file => new FileInfo(file).Length)
.ThenByDescending(File.GetLastWriteTimeUtc)
.FirstOrDefault();
if (string.IsNullOrWhiteSpace(path))
return new DrawingOwnershipData(summary, samples);
try
{
foreach (var line in File.ReadLines(path, Encoding.UTF8))
{
if (samples.Count >= 250000 || string.IsNullOrWhiteSpace(line))
break;
using var doc = JsonDocument.Parse(line);
var root = doc.RootElement;
if (!ReadJsonBool(root, "Hit"))
continue;
var x = ReadJsonDouble(root, "SheetXMm");
var y = ReadJsonDouble(root, "SheetYMm");
if (x == null || y == null)
continue;
if (!TryGetPropertyIgnoreCase(root, "Ownership", out var ownership) || ownership.ValueKind != JsonValueKind.Object)
continue;
var rawOwner = ReadJsonString(ownership, "EntityComponent");
var componentName = ExtractOwnershipComponentName(rawOwner);
if (string.IsNullOrWhiteSpace(componentName))
continue;
samples.Add(new DrawingOwnershipSample(componentName, rawOwner, x.Value, y.Value));
}
var components = samples
.GroupBy(sample => sample.ComponentName, StringComparer.OrdinalIgnoreCase)
.OrderByDescending(group => group.Count())
.Select(group => (JsonNode?)new JsonObject
{
["component_name"] = group.Key,
["sample_count"] = group.Count(),
["min_x_mm"] = group.Min(sample => sample.XMm),
["min_y_mm"] = group.Min(sample => sample.YMm),
["max_x_mm"] = group.Max(sample => sample.XMm),
["max_y_mm"] = group.Max(sample => sample.YMm)
})
.ToArray();
summary["available"] = samples.Count > 0;
summary["source_path"] = path;
summary["sample_count"] = samples.Count;
summary["component_count"] = components.Length;
summary["components"] = new JsonArray(components);
summary["binding_policy"] = "Dimension definition points are preferred over text position; nearest ownership samples identify component candidates, not exact B-rep faces.";
}
catch (Exception ex)
{
summary["source_path"] = path;
summary["error"] = ex.Message;
}
return new DrawingOwnershipData(summary, samples);
}
static string ExtractOwnershipComponentName(string raw)
{
if (string.IsNullOrWhiteSpace(raw) || raw.Equals("null", StringComparison.OrdinalIgnoreCase))
return "";
var match = Regex.Match(raw, @"(?:^|;)\s*name=(?<name>[^;]+)", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
return match.Success ? match.Groups["name"].Value.Trim() : raw.Trim();
}
JsonObject BuildAnnotationOwnershipBinding(JsonObject annotation, IReadOnlyList<DrawingOwnershipSample> samples)
{
var referencePoints = new List<DrawingReferencePoint>();
foreach (var field in new[] { "xline1_point", "xline2_point", "center", "chord_point", "dim_line_point", "insertion_point", "text_position" })
{
if (TryReadPoint(annotation[field], out var x, out var y))
referencePoints.Add(new DrawingReferencePoint(field, x, y, field == "text_position" ? 5 : 0));
}
if (referencePoints.Count == 0 && annotation["bbox"] is JsonObject bbox)
{
var minX = JsonDouble(bbox["min_x"]);
var minY = JsonDouble(bbox["min_y"]);
var maxX = JsonDouble(bbox["max_x"]);
var maxY = JsonDouble(bbox["max_y"]);
if (minX != null && minY != null && maxX != null && maxY != null)
referencePoints.Add(new DrawingReferencePoint("bbox_center", (minX.Value + maxX.Value) / 2, (minY.Value + maxY.Value) / 2, 5));
}
var candidates = samples
.GroupBy(sample => sample.ComponentName, StringComparer.OrdinalIgnoreCase)
.Select(ownerGroup =>
{
var nearest = (from point in referencePoints
from sample in ownerGroup
let rawDistance = Math.Sqrt(Math.Pow(point.XMm - sample.XMm, 2) + Math.Pow(point.YMm - sample.YMm, 2))
orderby rawDistance + point.PenaltyMm
select new { Point = point, Sample = sample, RawDistance = rawDistance, ScoreDistance = rawDistance + point.PenaltyMm }).FirstOrDefault();
return nearest == null ? null : new
{
Component = ownerGroup.Key,
nearest.Point,
nearest.Sample,
nearest.RawDistance,
nearest.ScoreDistance
};
})
.Where(item => item != null && item.ScoreDistance <= 20)
.OrderBy(item => item!.ScoreDistance)
.Take(6)
.ToList();
return new JsonObject
{
["status"] = samples.Count == 0 ? "ownership_data_unavailable" : referencePoints.Count == 0 ? "annotation_position_unavailable" : candidates.Count == 0 ? "no_nearby_owner" : "owner_candidates_found",
["reference_points"] = new JsonArray(referencePoints.Select(point => (JsonNode?)new JsonObject
{
["kind"] = point.Kind,
["x_mm"] = point.XMm,
["y_mm"] = point.YMm
}).ToArray()),
["owner_candidates"] = new JsonArray(candidates.Select(item => (JsonNode?)new JsonObject
{
["component_name"] = item!.Component,
["distance_mm"] = Math.Round(item.RawDistance, 3),
["reference_point_kind"] = item.Point.Kind,
["nearest_sample_x_mm"] = item.Sample.XMm,
["nearest_sample_y_mm"] = item.Sample.YMm,
["confidence"] = item.ScoreDistance <= 3 ? "high" : item.ScoreDistance <= 8 ? "medium" : "low"
}).ToArray()),
["face_binding_limit"] = "Ownership binds a drawing location to a component. Exact face identity still requires diameter and B-rep location/projection evidence."
};
}
JsonObject BuildDimensionDrawingGeometryBinding(
JsonObject annotation,
IReadOnlyList<DrawingGeometryEntity> geometry,
IReadOnlyList<DrawingOwnershipSample> ownershipSamples)
{
var pointBindings = new JsonArray();
foreach (var field in new[] { "xline1_point", "xline2_point" })
{
if (!TryReadPoint(annotation[field], out var x, out var y))
continue;
var candidates = geometry
.Select(entity => ScoreDrawingGeometryPoint(entity, x, y))
.Where(item => item.DistanceMm <= 2.0)
.OrderBy(item => item.DistanceMm)
.ThenBy(item => item.Entity.Handle, StringComparer.OrdinalIgnoreCase)
.Take(6)
.ToList();
pointBindings.Add(new JsonObject
{
["reference_point_kind"] = field,
["x_mm"] = x,
["y_mm"] = y,
["nearest_geometry"] = new JsonArray(candidates.Select(item => (JsonNode?)BuildDrawingGeometryCandidateNode(item, x, y, ownershipSamples)).ToArray())
});
}
return new JsonObject
{
["status"] = geometry.Count == 0 ? "geometry_unavailable" : pointBindings.Count == 0 ? "dimension_definition_points_unavailable" : "geometry_candidates_found",
["search_radius_mm"] = 2.0,
["point_bindings"] = pointBindings,
["binding_policy"] = "These are nearest DWG geometry candidates for dimension definition points. Use handle/distance as 2D evidence; final B-rep face identity still requires 3D diameter, ownership and projection checks."
};
}
static IReadOnlyList<DrawingGeometryEntity> ReadDrawingGeometryEntities(string dwgDir)
{
var path = Path.Combine(dwgDir, "geometry.json");
if (!File.Exists(path))
return [];
try
{
using var doc = JsonDocument.Parse(File.ReadAllText(path));
if (doc.RootElement.ValueKind != JsonValueKind.Array)
return [];
var result = new List<DrawingGeometryEntity>();
foreach (var element in doc.RootElement.EnumerateArray())
{
var objectName = ReadDrawingElementString(element, "object_name", "objectName");
var handle = ReadDrawingElementString(element, "handle");
if (string.IsNullOrWhiteSpace(handle) || string.IsNullOrWhiteSpace(objectName))
continue;
var start = ReadDrawingPoint(element, "start");
var end = ReadDrawingPoint(element, "end");
var center = ReadDrawingPoint(element, "center");
var bounds = ReadDrawingBounds(element);
var radius = ReadDrawingElementDouble(element, "radius", "radius_mm", "radiusMm");
if (radius == null && bounds.IsValid && objectName.Contains("Circle", StringComparison.OrdinalIgnoreCase))
radius = Math.Max(bounds.Width, bounds.Height) / 2.0;
result.Add(new DrawingGeometryEntity(
Handle: handle,
ObjectName: objectName,
Layer: ReadDrawingElementString(element, "layer"),
Start: start.Count >= 2 ? new DrawingPoint(start[0], start[1]) : null,
End: end.Count >= 2 ? new DrawingPoint(end[0], end[1]) : null,
Center: center.Count >= 2 ? new DrawingPoint(center[0], center[1]) : null,
Radius: radius,
Bounds: bounds));
}
return result;
}
catch
{
return [];
}
}
static DrawingGeometryScore ScoreDrawingGeometryPoint(DrawingGeometryEntity entity, double x, double y)
{
if (entity.Start is { } start && entity.End is { } end)
return new DrawingGeometryScore(entity, PointToSegmentDistance(x, y, start.X, start.Y, end.X, end.Y), "line_segment_distance");
if (entity.Center is { } center && entity.Radius is > 0)
return new DrawingGeometryScore(entity, Math.Abs(Distance(x, y, center.X, center.Y) - entity.Radius.Value), "circle_or_arc_radius_distance");
if (entity.Bounds.IsValid)
return new DrawingGeometryScore(entity, DistanceToBounds(x, y, entity.Bounds), "bbox_distance");
return new DrawingGeometryScore(entity, double.PositiveInfinity, "unscored");
}
static JsonObject BuildDrawingGeometryCandidateNode(
DrawingGeometryScore item,
double referenceX,
double referenceY,
IReadOnlyList<DrawingOwnershipSample> ownershipSamples)
{
return new JsonObject
{
["handle"] = item.Entity.Handle,
["object_name"] = item.Entity.ObjectName,
["layer"] = item.Entity.Layer,
["distance_mm"] = Math.Round(item.DistanceMm, 4),
["match_kind"] = item.MatchKind,
["confidence"] = item.DistanceMm <= 0.25 ? "high" : item.DistanceMm <= 1.0 ? "medium" : "low",
["start"] = PointToJson(item.Entity.Start),
["end"] = PointToJson(item.Entity.End),
["center"] = PointToJson(item.Entity.Center),
["radius_mm"] = item.Entity.Radius,
["bbox"] = item.Entity.Bounds.ToJson(),
["boundary_ownership"] = BuildGeometryBoundaryOwnership(item.Entity, referenceX, referenceY, ownershipSamples)
};
}
static JsonObject BuildGeometryBoundaryOwnership(
DrawingGeometryEntity entity,
double referenceX,
double referenceY,
IReadOnlyList<DrawingOwnershipSample> ownershipSamples)
{
if (ownershipSamples.Count == 0)
return new JsonObject { ["status"] = "ownership_data_unavailable" };
var probes = BuildBoundaryProbePoints(entity, referenceX, referenceY, 3.0);
if (probes.Count == 0)
return new JsonObject { ["status"] = "boundary_probe_not_supported_for_geometry" };
var sideNodes = new JsonArray();
var sideOwners = new List<(string Side, List<DrawingOwnershipNearest> Owners)>();
foreach (var probe in probes)
{
var owners = FindNearestOwnershipSamples(probe.Point.X, probe.Point.Y, ownershipSamples, 6.0, 3);
sideOwners.Add((probe.Side, owners));
sideNodes.Add(new JsonObject
{
["side"] = probe.Side,
["probe_x_mm"] = Math.Round(probe.Point.X, 3),
["probe_y_mm"] = Math.Round(probe.Point.Y, 3),
["owner_candidates"] = new JsonArray(owners.Select(owner => (JsonNode?)new JsonObject
{
["component_name"] = owner.Sample.ComponentName,
["distance_mm"] = Math.Round(owner.DistanceMm, 3),
["sample_x_mm"] = owner.Sample.XMm,
["sample_y_mm"] = owner.Sample.YMm,
["confidence"] = owner.DistanceMm <= 1.5 ? "high" : owner.DistanceMm <= 3.5 ? "medium" : "low"
}).ToArray())
});
}
var pairCandidates = BuildBoundaryComponentPairCandidates(sideOwners);
return new JsonObject
{
["status"] = pairCandidates.Count > 0 ? "boundary_pair_candidates_found" : "boundary_pair_not_resolved",
["probe_offset_mm"] = 3.0,
["sides"] = sideNodes,
["component_pair_candidates"] = pairCandidates,
["boundary_rule"] = "A visible drawing edge is treated as a possible boundary between two projected components; side probes provide drawing evidence, not final 3D contact proof."
};
}
static List<BoundaryProbePoint> BuildBoundaryProbePoints(DrawingGeometryEntity entity, double referenceX, double referenceY, double offsetMm)
{
if (entity.Start is { } start && entity.End is { } end)
{
var dx = end.X - start.X;
var dy = end.Y - start.Y;
var length = Math.Sqrt(dx * dx + dy * dy);
if (length <= 1e-9)
return [];
var nx = -dy / length;
var ny = dx / length;
return
[
new BoundaryProbePoint("normal_positive", new DrawingPoint(referenceX + nx * offsetMm, referenceY + ny * offsetMm)),
new BoundaryProbePoint("normal_negative", new DrawingPoint(referenceX - nx * offsetMm, referenceY - ny * offsetMm))
];
}
if (entity.Center is { } center)
{
var dx = referenceX - center.X;
var dy = referenceY - center.Y;
var length = Math.Sqrt(dx * dx + dy * dy);
if (length <= 1e-9)
return [];
var ux = dx / length;
var uy = dy / length;
return
[
new BoundaryProbePoint("radial_outside", new DrawingPoint(referenceX + ux * offsetMm, referenceY + uy * offsetMm)),
new BoundaryProbePoint("radial_inside", new DrawingPoint(referenceX - ux * offsetMm, referenceY - uy * offsetMm))
];
}
return [];
}
static List<DrawingOwnershipNearest> FindNearestOwnershipSamples(
double x,
double y,
IReadOnlyList<DrawingOwnershipSample> samples,
double maxDistanceMm,
int maxPerDistinctComponent)
{
return samples
.Select(sample => new DrawingOwnershipNearest(sample, Distance(x, y, sample.XMm, sample.YMm)))
.Where(item => item.DistanceMm <= maxDistanceMm)
.GroupBy(item => NormalizeComponentName(item.Sample.ComponentName), StringComparer.OrdinalIgnoreCase)
.Select(group => group.OrderBy(item => item.DistanceMm).First())
.Where(item => !string.IsNullOrWhiteSpace(item.Sample.ComponentName))
.OrderBy(item => item.DistanceMm)
.Take(maxPerDistinctComponent)
.ToList();
}
static JsonArray BuildBoundaryComponentPairCandidates(List<(string Side, List<DrawingOwnershipNearest> Owners)> sideOwners)
{
var result = new JsonArray();
if (sideOwners.Count < 2)
return result;
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var left in sideOwners[0].Owners)
{
foreach (var right in sideOwners[1].Owners)
{
var leftName = left.Sample.ComponentName;
var rightName = right.Sample.ComponentName;
if (string.IsNullOrWhiteSpace(leftName) || string.IsNullOrWhiteSpace(rightName))
continue;
if (ComponentNamesMatch(leftName, rightName))
continue;
var keyParts = new[] { NormalizeComponentName(leftName), NormalizeComponentName(rightName) }
.OrderBy(value => value, StringComparer.OrdinalIgnoreCase)
.ToArray();
var key = string.Join("|", keyParts);
if (!seen.Add(key))
continue;
var maxDistance = Math.Max(left.DistanceMm, right.DistanceMm);
result.Add(new JsonObject
{
["component_a"] = leftName,
["component_b"] = rightName,
["side_a"] = sideOwners[0].Side,
["side_b"] = sideOwners[1].Side,
["max_probe_distance_mm"] = Math.Round(maxDistance, 3),
["confidence"] = maxDistance <= 1.5 ? "high" : maxDistance <= 3.5 ? "medium" : "low"
});
}
}
return result;
}
static List<DrawingBoundaryComponentPair> ReadDrawingBoundaryComponentPairs(JsonObject annotation)
{
var result = new List<DrawingBoundaryComponentPair>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (annotation["drawing_geometry_binding"]?["point_bindings"] is not JsonArray pointBindings)
return result;
foreach (var pointBinding in pointBindings.OfType<JsonObject>())
{
var pointKind = JsonString(pointBinding["reference_point_kind"]);
if (pointBinding["nearest_geometry"] is not JsonArray geometries)
continue;
foreach (var geometry in geometries.OfType<JsonObject>())
{
var handle = JsonString(geometry["handle"]);
if (geometry["boundary_ownership"]?["component_pair_candidates"] is not JsonArray pairs)
continue;
foreach (var pair in pairs.OfType<JsonObject>())
{
var componentA = JsonString(pair["component_a"]);
var componentB = JsonString(pair["component_b"]);
if (string.IsNullOrWhiteSpace(componentA) || string.IsNullOrWhiteSpace(componentB))
continue;
var keyParts = new[] { NormalizeComponentName(componentA), NormalizeComponentName(componentB), handle, pointKind }
.OrderBy(value => value, StringComparer.OrdinalIgnoreCase)
.ToArray();
var key = string.Join("|", keyParts);
if (!seen.Add(key))
continue;
result.Add(new DrawingBoundaryComponentPair(
componentA,
componentB,
handle,
pointKind,
JsonDouble(pair["max_probe_distance_mm"]) ?? 0,
JsonString(pair["confidence"])));
}
}
}
return result;
}
static int CountBoundaryPairMatches(IReadOnlyList<DrawingBoundaryComponentPair> boundaryPairs, JsonObject interfaceGroup) =>
boundaryPairs.Count(pair => BoundaryPairMatchesInterface(pair, interfaceGroup));
static bool BoundaryPairMatchesInterface(DrawingBoundaryComponentPair pair, JsonObject interfaceGroup)
{
var componentA = JsonString(interfaceGroup["component_a"]);
var componentB = JsonString(interfaceGroup["component_b"]);
return (ComponentNamesMatch(pair.ComponentA, componentA) && ComponentNamesMatch(pair.ComponentB, componentB)) ||
(ComponentNamesMatch(pair.ComponentA, componentB) && ComponentNamesMatch(pair.ComponentB, componentA));
}
static JsonArray BuildBoundaryPairEvidence(IReadOnlyList<DrawingBoundaryComponentPair> boundaryPairs, JsonObject interfaceGroup)
{
return new JsonArray(boundaryPairs
.Where(pair => BoundaryPairMatchesInterface(pair, interfaceGroup))
.Select(pair => (JsonNode?)new JsonObject
{
["component_a"] = pair.ComponentA,
["component_b"] = pair.ComponentB,
["geometry_handle"] = pair.GeometryHandle,
["reference_point_kind"] = pair.ReferencePointKind,
["max_probe_distance_mm"] = Math.Round(pair.MaxProbeDistanceMm, 3),
["confidence"] = pair.Confidence
})
.ToArray());
}
static JsonArray BuildDrawingBoundaryTo3dInterfaceMatches(
IReadOnlyList<DrawingBoundaryComponentPair> boundaryPairs,
JsonArray groups,
double? interfaceDiameterMm)
{
var result = new JsonArray();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var pair in boundaryPairs)
{
foreach (var group in groups.OfType<JsonObject>())
{
if (!BoundaryPairMatchesInterface(pair, group))
continue;
var groupId = JsonString(group["interface_group_id"]);
var key = $"{pair.GeometryHandle}|{pair.ReferencePointKind}|{groupId}";
if (!seen.Add(key))
continue;
var groupDiameter = JsonDouble(group["diameter_mm"]);
var diameterTolerance = interfaceDiameterMm is > 0 ? Math.Max(0.05, interfaceDiameterMm.Value * 0.001) : 0.0;
var diameterMatches = interfaceDiameterMm is > 0 &&
groupDiameter is > 0 &&
Math.Abs(groupDiameter.Value - interfaceDiameterMm.Value) <= diameterTolerance;
result.Add(new JsonObject
{
["geometry_handle"] = pair.GeometryHandle,
["reference_point_kind"] = pair.ReferencePointKind,
["drawing_component_a"] = pair.ComponentA,
["drawing_component_b"] = pair.ComponentB,
["interface_group_id"] = groupId,
["interface_component_a"] = group["component_a"]?.DeepClone(),
["interface_component_b"] = group["component_b"]?.DeepClone(),
["diameter_mm"] = group["diameter_mm"]?.DeepClone(),
["contact_ids"] = group["contact_ids"]?.DeepClone(),
["brep_binding_status"] = diameterMatches
? "brep_face_pair_candidate_by_boundary_pair_and_nominal_diameter"
: "boundary_component_pair_match_without_nominal_diameter_match",
["brep_surface_parameter_summary"] = new JsonObject
{
["surface_kind"] = "cylindrical",
["drawing_nominal_diameter_mm"] = interfaceDiameterMm,
["interface_diameter_mm"] = group["diameter_mm"]?.DeepClone(),
["interface_radius_mm"] = group["radius_mm"]?.DeepClone(),
["diameter_matches"] = diameterMatches
},
["brep_face_pairs"] = group["face_pairs"]?.DeepClone(),
["interface_function_descriptions"] = group["interface_function_descriptions"]?.DeepClone(),
["connection_assessment"] = group["connection_assessment"]?.DeepClone(),
["match_basis"] = diameterMatches
? "drawing boundary component pair and nominal diameter match 3D B-rep contact face pair"
: "drawing boundary component pair matches 3D interface component pair, but nominal diameter did not match"
});
}
}
return result;
}
static double PointToSegmentDistance(double px, double py, double ax, double ay, double bx, double by)
{
var dx = bx - ax;
var dy = by - ay;
var len2 = dx * dx + dy * dy;
if (len2 <= 1e-12)
return Distance(px, py, ax, ay);
var t = ((px - ax) * dx + (py - ay) * dy) / len2;
t = Math.Max(0.0, Math.Min(1.0, t));
return Distance(px, py, ax + t * dx, ay + t * dy);
}
static double DistanceToBounds(double x, double y, DwgEntityBounds bounds)
{
var dx = x < bounds.MinX ? bounds.MinX - x : x > bounds.MaxX ? x - bounds.MaxX : 0.0;
var dy = y < bounds.MinY ? bounds.MinY - y : y > bounds.MaxY ? y - bounds.MaxY : 0.0;
return Math.Sqrt(dx * dx + dy * dy);
}
static JsonNode? PointToJson(DrawingPoint? point) =>
point == null ? null : new JsonArray(point.X, point.Y);
static string ReadDrawingElementString(JsonElement element, params string[] names)
{
foreach (var name in names)
{
if (!TryGetPropertyIgnoreCase(element, name, out var value) || value.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined)
continue;
return value.ValueKind == JsonValueKind.String ? value.GetString() ?? "" : value.ToString();
}
return "";
}
static double? ReadDrawingElementDouble(JsonElement element, params string[] names)
{
foreach (var name in names)
{
if (!TryGetPropertyIgnoreCase(element, name, out var value) || value.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined)
continue;
if (value.ValueKind == JsonValueKind.Number && value.TryGetDouble(out var number))
return number;
if (double.TryParse(value.ToString(), System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var parsed))
return parsed;
}
return null;
}
static List<double> ReadDrawingPoint(JsonElement element, string name)
{
if (!TryGetPropertyIgnoreCase(element, name, out var value) || value.ValueKind != JsonValueKind.Array)
return [];
var result = new List<double>();
foreach (var item in value.EnumerateArray())
{
if (item.ValueKind == JsonValueKind.Number && item.TryGetDouble(out var number))
result.Add(number);
}
return result;
}
static DwgEntityBounds ReadDrawingBounds(JsonElement element)
{
if (TryGetPropertyIgnoreCase(element, "bbox", out var bbox) && bbox.ValueKind == JsonValueKind.Object)
return ReadDrawingBoundsObject(bbox);
return ReadDrawingBoundsObject(element);
}
static DwgEntityBounds ReadDrawingBoundsObject(JsonElement element)
{
var minX = ReadDrawingElementDouble(element, "min_x", "minX");
var minY = ReadDrawingElementDouble(element, "min_y", "minY");
var maxX = ReadDrawingElementDouble(element, "max_x", "maxX");
var maxY = ReadDrawingElementDouble(element, "max_y", "maxY");
return minX != null && minY != null && maxX != null && maxY != null
? new DwgEntityBounds(minX.Value, minY.Value, maxX.Value, maxY.Value)
: DwgEntityBounds.Empty;
}
static int CountOwnershipMatches(JsonObject ownershipBinding, JsonObject interfaceGroup)
{
if (ownershipBinding["owner_candidates"] is not JsonArray owners)
return 0;
var componentA = JsonString(interfaceGroup["component_a"]);
var componentB = JsonString(interfaceGroup["component_b"]);
var matchedA = false;
var matchedB = false;
foreach (var owner in owners.OfType<JsonObject>())
{
var confidence = JsonString(owner["confidence"]);
if (confidence == "low")
continue;
var name = JsonString(owner["component_name"]);
matchedA |= ComponentNamesMatch(name, componentA);
matchedB |= ComponentNamesMatch(name, componentB);
}
return (matchedA ? 1 : 0) + (matchedB ? 1 : 0);
}
static JsonArray BuildOwnershipMatchedSides(JsonObject ownershipBinding, JsonObject interfaceGroup)
{
var result = new JsonArray();
if (ownershipBinding["owner_candidates"] is not JsonArray owners)
return result;
foreach (var side in new[] { "a", "b" })
{
var component = JsonString(interfaceGroup[$"component_{side}"]);
var matchingOwners = owners.OfType<JsonObject>()
.Where(owner => JsonString(owner["confidence"]) != "low" && ComponentNamesMatch(JsonString(owner["component_name"]), component))
.ToList();
if (matchingOwners.Count == 0)
continue;
var faces = new JsonArray();
if (interfaceGroup["face_pairs"] is JsonArray facePairs)
{
foreach (var pair in facePairs.OfType<JsonObject>())
{
var face = pair[$"face_{side}"];
if (face != null && !faces.Any(existing => JsonString(existing) == JsonString(face)))
faces.Add(face.DeepClone());
}
}
result.Add(new JsonObject
{
["side"] = side,
["component"] = component,
["face_refs"] = faces,
["owner_evidence"] = new JsonArray(matchingOwners.Select(owner => (JsonNode?)owner.DeepClone()).ToArray())
});
}
return result;
}
JsonObject BuildRoughnessAssessmentContext(string dwgDir, JsonNode assemblyFunctionContext, DrawingOwnershipData ownershipData)
{
var annotations = ReadParsedRoughnessAnnotations(dwgDir);
var interfaces = new List<JsonObject>();
if (assemblyFunctionContext["interface_function_profiles"]?["cylindrical"] is JsonArray cylindrical)
interfaces.AddRange(cylindrical.OfType<JsonObject>());
if (assemblyFunctionContext["interface_function_profiles"]?["planar"] is JsonArray planar)
interfaces.AddRange(planar.OfType<JsonObject>());
var candidates = new JsonArray();
var matchedInterfaceIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var annotation in annotations.OfType<JsonObject>())
{
var ownershipBinding = BuildAnnotationOwnershipBinding(annotation, ownershipData.Samples);
annotation["ownership_binding"] = ownershipBinding;
var matched = new JsonArray();
foreach (var profile in interfaces)
{
var ownerMatches = CountOwnershipMatches(ownershipBinding, profile);
if (ownerMatches == 0)
continue;
var clone = profile.DeepClone() as JsonObject ?? new JsonObject();
clone["ownership_component_match_count"] = ownerMatches;
clone["target_face_side_candidates"] = BuildOwnershipMatchedSides(ownershipBinding, profile);
matched.Add(clone);
matchedInterfaceIds.Add(JsonString(profile["interface_group_id"]));
}
var globalDefault = JsonString(annotation["scope"]) == "global_unspecified_surfaces";
candidates.Add(new JsonObject
{
["candidate_id"] = $"roughness_{JsonString(annotation["handle"])}",
["dwg_annotation"] = annotation.DeepClone(),
["related_3d_interfaces"] = matched,
["binding_status"] = globalDefault ? "global_default_not_face_specific" : matched.Count == 1 ? "matched_one_interface_by_ownership" : matched.Count > 1 ? "ambiguous_multiple_interfaces_on_owned_component" : "no_interface_binding",
["assessment_rule"] = "Judge each participating face separately from interface motion/load/sealing/assembly function and retrieved surface-finish knowledge."
});
}
var unannotated = new JsonArray();
foreach (var profile in interfaces)
{
var id = JsonString(profile["interface_group_id"]);
if (matchedInterfaceIds.Contains(id))
continue;
unannotated.Add(new JsonObject
{
["candidate_id"] = $"missing_roughness_{id}",
["related_3d_interface"] = profile.DeepClone(),
["preliminary_verdict"] = "needs_review",
["missing_rule"] = "Do not call this missing until the interface function, machining role, drawing scope and global roughness note are checked."
});
}
return new JsonObject
{
["schema"] = "agent4.drawing_assembly.roughness_assessment_context.v1",
["policy"] = new JsonArray
{
"Roughness is a per-surface requirement; the two faces of one contact may require different values.",
"Use interface function, relative motion, load transfer, sealing and assembly method before retrieving an expected Ra/Rz range.",
"A global '其余' note does not automatically satisfy critical mating, bearing, sliding, sealing or interference-fit surfaces.",
"Exact Ra/Rz values require a retrieved rule; geometry alone supplies intent, not the final number."
},
["drawing_ownership_source"] = ownershipData.Summary.DeepClone(),
["parsed_dwg_roughness_annotations"] = annotations,
["roughness_candidates"] = candidates,
["unannotated_interface_surface_candidates"] = unannotated
};
}
JsonArray ReadParsedRoughnessAnnotations(string dir)
{
var result = new JsonArray();
var path = Path.Combine(dir, "roughness.json");
if (!File.Exists(path))
return result;
try
{
using var doc = JsonDocument.Parse(File.ReadAllText(path));
if (doc.RootElement.ValueKind != JsonValueKind.Array)
return result;
foreach (var item in doc.RootElement.EnumerateArray())
{
var text = ReadJsonString(item, "text");
var valueMatch = Regex.Match(text, @"\bR(?<parameter>a|z|max)\s*(?<value>\d+(?:[.,]\d+)?)", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
var node = new JsonObject
{
["handle"] = ReadJsonString(item, "handle"),
["object_name"] = ReadJsonString(item, "object_name"),
["text"] = text,
["raw_text"] = ReadJsonString(item, "raw_text"),
["parameter"] = valueMatch.Success ? "R" + valueMatch.Groups["parameter"].Value : null,
["value_um"] = valueMatch.Success && double.TryParse(valueMatch.Groups["value"].Value.Replace(',', '.'), System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var value) ? value : null,
["scope"] = text.Contains("其余", StringComparison.OrdinalIgnoreCase) ? "global_unspecified_surfaces" : "specific_or_unknown_surface"
};
foreach (var locationField in new[] { "insertion_point", "text_position", "bbox" })
{
if (TryGetPropertyIgnoreCase(item, locationField, out var location))
node[locationField] = JsonNode.Parse(location.GetRawText());
}
result.Add(node);
}
}
catch (Exception ex)
{
result.Add(new JsonObject { ["source_path"] = path, ["error"] = ex.Message });
}
return result;
}
static bool TryReadPoint(JsonNode? node, out double x, out double y)
{
x = 0;
y = 0;
if (node is not JsonArray point || point.Count < 2)
return false;
var parsedX = JsonDouble(point[0]);
var parsedY = JsonDouble(point[1]);
if (parsedX == null || parsedY == null)
return false;
x = parsedX.Value;
y = parsedY.Value;
return true;
}
JsonArray ReadParsedToleranceAnnotations(string dir)
{
var result = new JsonArray();
var path = Path.Combine(dir, "dimensional-tolerances.json");
if (!File.Exists(path))
return result;
try
{
var dimensionLocations = ReadDimensionLocationsByHandle(dir);
using var doc = JsonDocument.Parse(File.ReadAllText(path));
if (doc.RootElement.ValueKind != JsonValueKind.Array)
return result;
foreach (var item in doc.RootElement.EnumerateArray())
{
var text = ReadJsonString(item, "text");
var rawText = ReadJsonString(item, "raw_text");
var measurement = ReadJsonDouble(item, "measurement");
var objectName = ReadJsonString(item, "object_name");
var parsed = JsonSerializer.SerializeToNode(
DimensionalToleranceParser.Parse(text, rawText, measurement, objectName),
JsonOptions) as JsonObject ?? new JsonObject();
parsed["handle"] = ReadJsonString(item, "handle");
parsed["object_name"] = objectName;
parsed["source_index"] = ReadJsonInt(item, "index");
var handle = ReadJsonString(item, "handle");
foreach (var locationField in new[] { "text_position", "xline1_point", "xline2_point", "dim_line_point", "center", "chord_point", "bbox" })
{
if (TryGetPropertyIgnoreCase(item, locationField, out var location))
parsed[locationField] = JsonNode.Parse(location.GetRawText());
else if (dimensionLocations.TryGetValue(handle, out var dimension) && TryGetPropertyIgnoreCase(dimension, locationField, out var dimensionLocation))
parsed[locationField] = JsonNode.Parse(dimensionLocation.GetRawText());
}
result.Add(parsed);
}
}
catch (Exception ex)
{
result.Add(new JsonObject { ["parse_error"] = ex.Message, ["source_path"] = path });
}
return result;
}
static Dictionary<string, JsonElement> ReadDimensionLocationsByHandle(string dir)
{
var result = new Dictionary<string, JsonElement>(StringComparer.OrdinalIgnoreCase);
var path = Path.Combine(dir, "dimensions.json");
if (!File.Exists(path))
return result;
try
{
using var doc = JsonDocument.Parse(File.ReadAllText(path));
if (doc.RootElement.ValueKind != JsonValueKind.Array)
return result;
foreach (var item in doc.RootElement.EnumerateArray())
{
var handle = ReadJsonString(item, "handle");
if (!string.IsNullOrWhiteSpace(handle))
result[handle] = item.Clone();
}
}
catch
{
// Location backfill is optional; parsed tolerance text remains usable.
}
return result;
}
static List<JsonElement> ReadInterfaceSemanticUnits(string dir)
{
var path = ResolveMechanicalContextFile(dir, "mechanical_semantic_graph.json");
if (string.IsNullOrWhiteSpace(path))
return [];
try
{
using var doc = JsonDocument.Parse(File.ReadAllText(path));
if (!TryGetPropertyIgnoreCase(doc.RootElement, "local_semantic_units", out var units) || units.ValueKind != JsonValueKind.Array)
return [];
return units.EnumerateArray()
.Where(unit => string.Equals(ReadJsonString(unit, "anchor_type"), "interface", StringComparison.OrdinalIgnoreCase))
.Select(unit => unit.Clone())
.ToList();
}
catch
{
return [];
}
}
static string ResolveMechanicalContextFile(string dir, string fileName)
{
if (!Directory.Exists(dir))
return Path.Combine(dir, fileName);
var nested = Directory.EnumerateFiles(dir, fileName, SearchOption.AllDirectories)
.OrderByDescending(File.GetLastWriteTimeUtc)
.FirstOrDefault();
if (!string.IsNullOrWhiteSpace(nested))
return nested;
var parent = Directory.GetParent(dir);
for (var depth = 0; depth < 2 && parent != null; depth++, parent = parent.Parent)
{
var candidate = Path.Combine(parent.FullName, fileName);
if (File.Exists(candidate))
return candidate;
var nestedParent = Directory.EnumerateFiles(parent.FullName, fileName, SearchOption.AllDirectories)
.OrderByDescending(File.GetLastWriteTimeUtc)
.FirstOrDefault();
if (!string.IsNullOrWhiteSpace(nestedParent))
return nestedParent;
}
return Path.Combine(dir, fileName);
}
static string ResolveSectionReportPath(string dir) => EnumerateSectionReportsNear(dir)
.OrderByDescending(path => path.Replace('\\', '/').Contains("external_interface", StringComparison.OrdinalIgnoreCase))
.ThenByDescending(File.GetLastWriteTimeUtc)
.FirstOrDefault() ?? "";
static IEnumerable<string> EnumerateSectionReportsNear(string dir)
{
if (!Directory.Exists(dir))
return [];
var paths = Directory.EnumerateFiles(dir, "section_brep_report.json", SearchOption.AllDirectories).ToList();
var parent = Directory.GetParent(dir);
if (parent != null && paths.Count <= 1)
paths.AddRange(Directory.EnumerateFiles(parent.FullName, "section_brep_report.json", SearchOption.AllDirectories));
return paths.Distinct(StringComparer.OrdinalIgnoreCase);
}
static JsonArray MatchInterfaceFunctions(
IEnumerable<JsonElement> units,
string componentA,
string componentB,
IReadOnlySet<string> contactIds)
{
var result = new JsonArray();
var normalizedA = NormalizeComponentName(componentA);
var normalizedB = NormalizeComponentName(componentB);
foreach (var unit in units)
{
var serialized = unit.GetRawText();
var exactContactMatch = contactIds.Any(id => serialized.Contains(id, StringComparison.OrdinalIgnoreCase));
var normalizedUnit = NormalizeComponentName(serialized);
var componentPairMatch = normalizedA.Length >= 4 && normalizedB.Length >= 4 &&
normalizedUnit.Contains(normalizedA, StringComparison.OrdinalIgnoreCase) &&
normalizedUnit.Contains(normalizedB, StringComparison.OrdinalIgnoreCase);
if (!exactContactMatch && !componentPairMatch)
continue;
var chain = TryGetPropertyIgnoreCase(unit, "retrieval_chain", out var chainElement) ? chainElement : default;
result.Add(new JsonObject
{
["semantic_unit_id"] = ReadJsonString(unit, "local_id"),
["retrieval_sentence"] = ReadJsonString(unit, "retrieval_sentence"),
["mechanical_role"] = chain.ValueKind == JsonValueKind.Object ? ReadJsonString(chain, "mechanical_role") : "",
["assembly_process_semantics"] = chain.ValueKind == JsonValueKind.Object ? ReadJsonString(chain, "process_assembly_maintenance_semantics") : "",
["match_method"] = exactContactMatch ? "exact_contact_id" : "component_pair_text"
});
if (result.Count >= 5)
break;
}
return result;
}
static string NormalizeComponentName(string value)
{
value = Regex.Replace(value ?? "", @"-\d+\b", "", RegexOptions.CultureInvariant);
return Regex.Replace(value, @"[^\p{L}\p{N}]", "", RegexOptions.CultureInvariant).ToLowerInvariant();
}
static bool ComponentNamesMatch(string left, string right)
{
var a = NormalizeComponentName(left);
var b = NormalizeComponentName(right);
return a.Length >= 3 && b.Length >= 3 &&
(a.Equals(b, StringComparison.OrdinalIgnoreCase) || a.Contains(b, StringComparison.OrdinalIgnoreCase) || b.Contains(a, StringComparison.OrdinalIgnoreCase));
}
static bool TryGetPropertyIgnoreCase(JsonElement element, string name, out JsonElement value)
{
if (element.ValueKind == JsonValueKind.Object)
{
foreach (var property in element.EnumerateObject())
{
if (string.Equals(property.Name, name, StringComparison.OrdinalIgnoreCase))
{
value = property.Value;
return true;
}
}
}
value = default;
return false;
}
static string ReadJsonString(JsonElement element, string name) =>
TryGetPropertyIgnoreCase(element, name, out var value) && value.ValueKind == JsonValueKind.String
? value.GetString() ?? ""
: "";
static double? ReadJsonDouble(JsonElement element, string name) =>
TryGetPropertyIgnoreCase(element, name, out var value) && value.ValueKind == JsonValueKind.Number && value.TryGetDouble(out var number)
? number
: null;
static int? ReadJsonInt(JsonElement element, string name) =>
TryGetPropertyIgnoreCase(element, name, out var value) && value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var number)
? number
: null;
static bool ReadJsonBool(JsonElement element, string name) =>
TryGetPropertyIgnoreCase(element, name, out var value) && value.ValueKind == JsonValueKind.True;
static JsonNode? ReadJsonNode(JsonElement element, string name) =>
TryGetPropertyIgnoreCase(element, name, out var value) && value.ValueKind is not JsonValueKind.Undefined
? JsonNode.Parse(value.GetRawText())
: null;
JsonNode SummarizeJsonFile(string path, int maxItems)
{
if (!File.Exists(path))
return new JsonObject { ["exists"] = false, ["path"] = path };
try
{
using var doc = JsonDocument.Parse(File.ReadAllText(path));
var root = doc.RootElement;
return new JsonObject
{
["exists"] = true,
["path"] = path,
["summary"] = SummarizeElement(root, maxItems)
};
}
catch (Exception ex)
{
return new JsonObject { ["exists"] = true, ["path"] = path, ["error"] = ex.Message };
}
}
JsonNode SummarizeElement(JsonElement element, int maxItems)
{
if (element.ValueKind == JsonValueKind.Array)
{
var arr = new JsonArray();
var count = 0;
foreach (var item in element.EnumerateArray())
{
if (count++ >= maxItems)
break;
arr.Add(SummarizeElement(item, Math.Max(8, maxItems / 4)));
}
return new JsonObject { ["kind"] = "array", ["count"] = element.GetArrayLength(), ["items"] = arr };
}
if (element.ValueKind == JsonValueKind.Object)
{
var obj = new JsonObject { ["kind"] = "object" };
foreach (var prop in element.EnumerateObject().Take(maxItems))
{
if (prop.Value.ValueKind is JsonValueKind.Object or JsonValueKind.Array)
obj[prop.Name] = SummarizeElement(prop.Value, Math.Max(8, maxItems / 4));
else
obj[prop.Name] = JsonValueToNode(prop.Value);
}
return obj;
}
return JsonValueToNode(element) ?? JsonValue.Create("");
}
static JsonNode? JsonValueToNode(JsonElement element) => element.ValueKind switch
{
JsonValueKind.String => JsonValue.Create(Truncate(element.GetString() ?? "", 500)),
JsonValueKind.Number => element.TryGetInt64(out var i) ? JsonValue.Create(i) : JsonValue.Create(element.GetDouble()),
JsonValueKind.True => JsonValue.Create(true),
JsonValueKind.False => JsonValue.Create(false),
JsonValueKind.Null => null,
_ => JsonValue.Create(element.ToString())
};
JsonArray BuildRetrievalQueries(JsonNode candidates, JsonNode facts)
{
var queries = new JsonArray();
AddToleranceRetrievalQueries(facts, queries);
AddRoughnessRetrievalQueries(facts, queries);
AddDefaultQueries(queries);
CollectQueries(candidates, queries);
var uniqueQueries = queries
.Select(node => node?.GetValue<string>() ?? "")
.Where(text => !string.IsNullOrWhiteSpace(text))
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(80)
.ToList();
var result = new JsonArray();
foreach (var query in uniqueQueries)
result.Add(query);
return result;
}
static void AddDefaultQueries(JsonArray queries)
{
foreach (var query in new[]
{
"装配图 线型 字号 尺寸样式 格式错误",
"装配图 配合尺寸 公差 标注方法 漏标",
"装配图 粗糙度 标注 错误 漏标",
"装配图 剖视 局部剖视 孔 内部结构 表达"
})
{
queries.Add(query);
}
foreach (var query in new[]
{
"装配图 线型 线宽 字号 尺寸样式 格式错误",
"装配图 配合尺寸 公差 标注方法 漏标",
"装配图 粗糙度 基准 形位公差 标注错误",
"装配图 剖视 局部剖视 孔 槽 内部结构 表达不清晰"
})
{
queries.Add(query);
}
queries.Add("GB/T 1800 极限与配合 基本偏差 标准公差等级 孔轴公差带 标注检查");
queries.Add("附表6-4 基孔制配合的优先配合 H7 间隙配合 过渡配合 过盈配合");
queries.Add("附表6-5 基轴制配合的优先配合 h6 孔公差带");
}
static void AddToleranceRetrievalQueries(JsonNode facts, JsonArray queries)
{
if (facts["tolerance_assessment_context"]?["tolerance_candidates"] is not JsonArray candidates)
return;
foreach (var candidate in candidates.OfType<JsonObject>())
{
var annotation = candidate["dwg_annotation"] as JsonObject;
var nominal = JsonDouble(annotation?["nominal_size_mm"]);
var dimensionKind = JsonString(annotation?["dimension_kind"]);
var fitCode = JsonString(annotation?["fit_code"]);
var annotationText = JsonString(annotation?["text"]);
queries.Add($"GB/T 1800 基本尺寸 {nominal?.ToString("0.###", System.Globalization.CultureInfo.InvariantCulture)} {fitCode} {dimensionKind} 极限偏差 公差带 正确性 {annotationText}".Trim());
if (candidate["related_3d_interfaces"] is not JsonArray interfaces)
continue;
foreach (var item in interfaces.OfType<JsonObject>().Take(4))
{
var diameter = JsonDouble(item["diameter_mm"]);
var componentA = JsonString(item["component_a"]);
var componentB = JsonString(item["component_b"]);
var function = item["interface_function_descriptions"] is JsonArray functions
? string.Join(" ", functions.OfType<JsonObject>().Select(value => JsonString(value["mechanical_role"])).Where(value => !string.IsNullOrWhiteSpace(value)))
: "";
var connection = item["connection_assessment"]?.ToJsonString() ?? "";
queries.Add($"装配图 直径{diameter?.ToString("0.###", System.Globalization.CultureInfo.InvariantCulture)} {componentA} {componentB} {function} {Truncate(connection, 600)} 配合选择 间隙 过渡 过盈 公差等级 固定方式");
}
}
}
static void AddRoughnessRetrievalQueries(JsonNode facts, JsonArray queries)
{
if (facts["roughness_assessment_context"]?["roughness_candidates"] is JsonArray candidates)
{
foreach (var candidate in candidates.OfType<JsonObject>())
{
var annotation = candidate["dwg_annotation"] as JsonObject;
queries.Add($"装配图 表面粗糙度 {JsonString(annotation?["text"])} {JsonString(annotation?["parameter"])} {JsonDouble(annotation?["value_um"])} 标注范围 正确性");
if (candidate["related_3d_interfaces"] is not JsonArray interfaces)
continue;
foreach (var item in interfaces.OfType<JsonObject>().Take(4))
{
var function = item["interface_function_descriptions"]?.ToJsonString() ?? "";
var connection = item["connection_assessment"]?.ToJsonString() ?? "";
queries.Add($"{JsonString(item["component_a"])} {JsonString(item["component_b"])} {Truncate(function, 500)} {Truncate(connection, 500)} 配合面 定位面 滑动面 压入面 密封面 表面粗糙度 Ra Rz 选择");
}
}
}
queries.Add("装配图 其余表面粗糙度 关键配合面 单独标注 Ra Rz 检查");
queries.Add("过盈配合 压入配合 圆柱配合 表面粗糙度 选择");
queries.Add("滑动配合 转动配合 轴承配合 定位配合 表面粗糙度 选择");
}
static void CollectQueries(JsonNode? node, JsonArray queries)
{
if (node == null)
return;
if (node is JsonObject obj)
{
foreach (var prop in obj)
{
if (IsRetrievalQueryField(prop.Key) && prop.Value is JsonValue value)
{
var text = JsonString(value);
if (!string.IsNullOrWhiteSpace(text))
queries.Add(text);
}
else
{
CollectQueries(prop.Value, queries);
}
}
}
else if (node is JsonArray arr)
{
foreach (var item in arr)
CollectQueries(item, queries);
}
}
static bool IsRetrievalQueryField(string key) =>
string.Equals(key, "retrieval_query", StringComparison.OrdinalIgnoreCase) ||
string.Equals(key, "dynamic_scout_block", StringComparison.OrdinalIgnoreCase) ||
string.Equals(key, "scout_chunk_id", StringComparison.OrdinalIgnoreCase) ||
string.Equals(key, "knowledge_chunk_id", StringComparison.OrdinalIgnoreCase) ||
string.Equals(key, "issue_type", StringComparison.OrdinalIgnoreCase);
JsonArray SearchKnowledge(JsonArray queries)
{
var files = ResolveKnowledgeFiles();
var results = new JsonArray();
foreach (var queryNode in queries)
{
var query = queryNode?.GetValue<string>() ?? "";
if (string.IsNullOrWhiteSpace(query))
continue;
var structuredHits = SearchDrawingAssemblyKnowledge(query)
.OrderByDescending(hit => JsonInt(hit["score"]))
.Take(8)
.ToArray();
var fallbackHits = files
.Where(File.Exists)
.SelectMany(file => SearchFile(file, query))
.OrderByDescending(hit => hit.Score)
.Take(Math.Max(0, 8 - structuredHits.Length))
.ToArray();
var hits = new JsonArray();
foreach (var hit in structuredHits)
hits.Add(hit);
foreach (var hit in fallbackHits)
{
hits.Add(new JsonObject
{
["source_file"] = hit.SourceFile,
["source_type"] = "line_fallback",
["score"] = hit.Score,
["text"] = hit.Text
});
}
results.Add(new JsonObject
{
["query"] = query,
["hits"] = hits
});
}
return results;
}
IEnumerable<JsonObject> SearchDrawingAssemblyKnowledge(string query)
{
var indexPath = ResolveDrawingAssemblyKnowledgeIndexPath();
if (string.IsNullOrWhiteSpace(indexPath) || !File.Exists(indexPath))
yield break;
var index = LoadDrawingAssemblyKnowledgeIndex();
if (index?["layers"] is not JsonObject layers)
yield break;
var terms = BuildSearchTerms(query);
if (terms.Count == 0)
yield break;
foreach (var layerProp in layers)
{
if (layerProp.Value is not JsonObject layer || layer["chunks"] is not JsonArray chunks)
continue;
foreach (var chunk in chunks.OfType<JsonObject>())
{
var searchable = chunk.ToJsonString();
var score = terms.Sum(term => searchable.Contains(term, StringComparison.OrdinalIgnoreCase) ? 1 : 0);
if (score <= 0)
continue;
yield return new JsonObject
{
["source_file"] = indexPath,
["source_type"] = "drawing_assembly_diagnostic_index",
["score"] = score,
["layer"] = layerProp.Key,
["layer_name"] = JsonString(layer["name"]),
["chunk_id"] = JsonString(chunk["chunk_id"]),
["dynamic_scout_block"] = JsonString(chunk["dynamic_scout_block"]),
["retrieval_metadata"] = chunk["retrieval_metadata"]?.DeepClone(),
["content"] = chunk["content"]?.DeepClone()
};
}
}
}
IReadOnlyList<string> ResolveKnowledgeFiles()
{
var files = new List<string>();
files.AddRange(Directory.EnumerateFiles(_paths.Agent4Root, "chapter6_chunks_v1.csv", SearchOption.AllDirectories)
.Where(path => path.Replace('\\', '/').Contains("/drawing_index/", StringComparison.OrdinalIgnoreCase))
.Take(3));
var taboo = Path.Combine(_paths.MechanicalKnowledgeIndexRoot, "mechanical_design_taboo_diagnostic_index.json");
if (File.Exists(taboo))
files.Add(taboo);
return files.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
}
IEnumerable<KnowledgeHit> SearchFile(string file, string query)
{
var terms = BuildSearchTerms(query);
if (terms.Count == 0)
yield break;
var lineNo = 0;
foreach (var line in File.ReadLines(file).Take(50000))
{
lineNo++;
var text = line.Trim();
if (text.Length < 10)
continue;
var score = terms.Sum(term => text.Contains(term, StringComparison.OrdinalIgnoreCase) ? 1 : 0);
if (score <= 0)
continue;
yield return new KnowledgeHit(file, score, $"L{lineNo}: {Truncate(text, 900)}");
}
}
static List<string> BuildSearchTerms(string query) =>
Regex.Split(query, @"[\s,;:/\\|,。;、()()\[\]{}<>]+")
.Select(term => term.Trim())
.Where(term => term.Length >= 2)
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(16)
.ToList();
static string JsonString(JsonNode? node)
{
if (node == null)
return "";
try
{
if (node is JsonValue value)
return value.GetValue<string>() ?? "";
}
catch
{
return node.ToJsonString();
}
return node.ToJsonString();
}
static int JsonInt(JsonNode? node)
{
try
{
return node is JsonValue value ? value.GetValue<int>() : 0;
}
catch
{
return 0;
}
}
static double? JsonDouble(JsonNode? node)
{
try
{
if (node is not JsonValue value)
return null;
if (value.TryGetValue<double>(out var number))
return number;
if (value.TryGetValue<string>(out var text) &&
double.TryParse(text, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var parsed))
return parsed;
}
catch
{
return null;
}
return null;
}
static JsonNode ExtractJsonOrFallback(string text, JsonNode fallback)
{
var candidates = new List<string>();
var codeBlock = Regex.Match(text, "```(?:json)?\\s*([\\s\\S]*?)```", RegexOptions.IgnoreCase);
if (codeBlock.Success)
candidates.Add(codeBlock.Groups[1].Value);
var firstObject = text.IndexOf('{');
var lastObject = text.LastIndexOf('}');
if (firstObject >= 0 && lastObject > firstObject)
candidates.Add(text[firstObject..(lastObject + 1)]);
foreach (var candidate in candidates)
{
try
{
return JsonNode.Parse(candidate) ?? fallback;
}
catch
{
// Try next candidate.
}
}
return fallback;
}
static JsonNode BuildFallbackProbe() => new JsonObject
{
["schema"] = "agent4.drawing_assembly.ai_view_probe.v1",
["visual_observations"] = new JsonArray(),
["format_check_needs"] = new JsonArray("线型、线宽、文字高度、尺寸样式结构化事实"),
["annotation_check_needs"] = new JsonArray("尺寸、公差、粗糙度、基准、技术要求抽取事实和三维配合关系"),
["representation_check_needs"] = new JsonArray("孔、槽、内部结构、剖视和局部剖视需求的三维事实"),
["needed_3d_facts"] = new JsonArray("组件功能、配合关系、孔组和内部结构"),
["needed_dwg_facts"] = new JsonArray("尺寸、公差、粗糙度、文字、图层、线型、视图区域"),
["risk_areas"] = new JsonArray()
};
static JsonNode BuildFallbackCandidates() => new JsonObject
{
["schema"] = "agent4.drawing_assembly.layered_candidate_issues.v1",
["format_errors"] = new JsonArray(),
["annotation_errors"] = new JsonArray(),
["representation_errors"] = new JsonArray()
};
static IReadOnlyList<MechanicalEvidenceImage> BuildImagePair(DrawingAssemblyViewInput input) =>
[
new MechanicalEvidenceImage { Name = "学生原始装配图视图", FilePath = input.StudentViewImage, ContentType = ContentTypeForImage(input.StudentViewImage) },
new MechanicalEvidenceImage { Name = "归属信息图/上色图", FilePath = input.OwnershipImage, ContentType = ContentTypeForImage(input.OwnershipImage) }
];
static string ResolveImagePath(string requested, string workDir, IReadOnlyList<string> hints)
{
if (!string.IsNullOrWhiteSpace(requested))
return Path.GetFullPath(requested.Trim().Trim('"'));
if (string.IsNullOrWhiteSpace(workDir) || !Directory.Exists(workDir))
return "";
return Directory.EnumerateFiles(workDir, "*.*", SearchOption.AllDirectories)
.Where(IsImagePath)
.Select(path => new
{
Path = path,
Score = hints.Count(hint => path.Contains(hint, StringComparison.OrdinalIgnoreCase))
})
.OrderByDescending(item => item.Score)
.ThenBy(item => item.Path.Length)
.FirstOrDefault(item => item.Score > 0)?.Path ?? "";
}
static void EnsureImageFile(string path, string name)
{
if (string.IsNullOrWhiteSpace(path))
throw new FileNotFoundException($"{name} 不能为空。请提供学生原始视图 PNG 和归属/上色 PNG。");
if (!File.Exists(path))
throw new FileNotFoundException($"{name} 不存在:{path}");
if (!IsImagePath(path))
throw new InvalidOperationException($"{name} 必须是图片文件,不能是 DWG{path}");
}
string PrepareOutputDir(string requested, string defaultRuntimeSubdir = "drawing-assembly-diagnostics")
{
var dir = string.IsNullOrWhiteSpace(requested)
? Path.Combine(_paths.RuntimeDir, defaultRuntimeSubdir, DateTime.Now.ToString("yyyyMMdd_HHmmss"))
: Path.GetFullPath(requested.Trim().Trim('"'));
Directory.CreateDirectory(dir);
Directory.CreateDirectory(Path.Combine(dir, "input"));
return dir;
}
static string ResolveOptionalDir(string path)
{
if (string.IsNullOrWhiteSpace(path))
return "";
return Path.GetFullPath(path.Trim().Trim('"'));
}
string ResolveMechanicalContextDir(string requestedDir, string modelPath, string fallbackDir)
{
var requested = ResolveOptionalDir(requestedDir);
if (!string.IsNullOrWhiteSpace(requested))
return requested;
if (!Directory.Exists(_paths.RuntimeDir))
return fallbackDir;
var normalizedModelPath = Path.GetFullPath(modelPath);
foreach (var reportPath in Directory.EnumerateFiles(_paths.RuntimeDir, "section_brep_report.json", SearchOption.AllDirectories)
.OrderByDescending(path => path.Replace('\\', '/').Contains("external_interface", StringComparison.OrdinalIgnoreCase))
.ThenByDescending(File.GetLastWriteTimeUtc))
{
try
{
using var doc = JsonDocument.Parse(File.ReadAllText(reportPath));
var reportModelPath = ReadJsonString(doc.RootElement, "Path");
if (string.IsNullOrWhiteSpace(reportModelPath) ||
!string.Equals(Path.GetFullPath(reportModelPath), normalizedModelPath, StringComparison.OrdinalIgnoreCase))
continue;
return Path.GetDirectoryName(reportPath) ?? fallbackDir;
}
catch
{
// Skip stale or incomplete reports and continue looking for the same model.
}
}
return fallbackDir;
}
static string ResolveOptionalFile(string path)
{
if (string.IsNullOrWhiteSpace(path))
return "";
return Path.GetFullPath(path.Trim().Trim('"'));
}
static bool IsImagePath(string path)
{
var ext = Path.GetExtension(path).ToLowerInvariant();
return ext is ".png" or ".jpg" or ".jpeg" or ".webp";
}
static string ContentTypeForImage(string path) => Path.GetExtension(path).ToLowerInvariant() switch
{
".jpg" or ".jpeg" => "image/jpeg",
".webp" => "image/webp",
_ => "image/png"
};
static string ToPromptJson(JsonNode node, int maxLength)
{
var text = node.ToJsonString(JsonOptions);
return Truncate(text, maxLength);
}
static string Truncate(string text, int maxLength)
{
if (string.IsNullOrEmpty(text) || text.Length <= maxLength)
return text;
return text[..maxLength] + "\n...TRUNCATED...";
}
static string FirstNonEmpty(params string?[] values)
{
foreach (var value in values)
{
if (!string.IsNullOrWhiteSpace(value))
return value.Trim();
}
return "";
}
static string ReadString(JsonElement element, string name)
{
return element.ValueKind == JsonValueKind.Object &&
element.TryGetProperty(name, out var value) &&
value.ValueKind != JsonValueKind.Null
? value.ToString()
: "";
}
static void WriteJson(string path, object data)
{
Directory.CreateDirectory(Path.GetDirectoryName(path) ?? ".");
File.WriteAllText(path, JsonSerializer.Serialize(data, JsonOptions), new UTF8Encoding(false));
}
List<DrawingAssemblyArtifact> BuildArtifacts(string outputDir)
{
return Directory.EnumerateFiles(outputDir, "*.*", SearchOption.AllDirectories)
.Where(path => path.EndsWith(".json", StringComparison.OrdinalIgnoreCase) ||
path.EndsWith(".md", StringComparison.OrdinalIgnoreCase))
.Select(path => new FileInfo(path))
.OrderBy(file => file.FullName)
.Select(file => new DrawingAssemblyArtifact
{
Name = Path.GetRelativePath(outputDir, file.FullName).Replace('\\', '/'),
FilePath = file.FullName,
Exists = file.Exists,
Length = file.Exists ? file.Length : 0,
LastModified = file.Exists ? file.LastWriteTime : null
})
.ToList();
}
void WriteLatestPointer(string outputDir, string defaultRuntimeSubdir = "drawing-assembly-diagnostics")
{
var dir = Path.Combine(_paths.RuntimeDir, defaultRuntimeSubdir);
Directory.CreateDirectory(dir);
WriteJson(Path.Combine(dir, "latest.json"), new
{
ok = true,
output_dir = outputDir,
updated_at = DateTimeOffset.Now
});
}
}
static class DrawingAssemblyAiClient
{
public static async Task<DrawingAssemblyAiResponse> CallAsync(
string model,
string apiKey,
string baseUrl,
string apiMode,
string proxy,
string prompt,
IReadOnlyList<MechanicalEvidenceImage> images,
int maxImages,
string rawResponsePath)
{
Directory.CreateDirectory(Path.GetDirectoryName(rawResponsePath) ?? ".");
using var handler = new HttpClientHandler();
if (!string.IsNullOrWhiteSpace(proxy))
{
handler.UseProxy = true;
handler.Proxy = new WebProxy(proxy);
}
var attachedImages = images
.Where(image => !string.IsNullOrWhiteSpace(image.FilePath) && File.Exists(image.FilePath))
.Take(Math.Max(0, maxImages))
.ToList();
using var http = new HttpClient(handler) { Timeout = TimeSpan.FromMinutes(8) };
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
var endpoint = string.Equals(apiMode, "responses", StringComparison.OrdinalIgnoreCase)
? $"{baseUrl.TrimEnd('/')}/responses"
: $"{baseUrl.TrimEnd('/')}/chat/completions";
object request = string.Equals(apiMode, "responses", StringComparison.OrdinalIgnoreCase)
? new
{
model,
input = new object[]
{
new
{
role = "user",
content = BuildResponsesContent(prompt, attachedImages)
}
}
}
: new
{
model,
messages = new object[]
{
new
{
role = "user",
content = attachedImages.Count == 0 ? (object)prompt : BuildChatContent(prompt, attachedImages)
}
}
};
using var content = new StringContent(JsonSerializer.Serialize(request, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
}), Encoding.UTF8, "application/json");
HttpResponseMessage response;
string raw;
try
{
response = await http.PostAsync(endpoint, content);
raw = await response.Content.ReadAsStringAsync();
}
catch (TaskCanceledException)
{
raw = "AI request timed out after 8 minutes.";
File.WriteAllText(rawResponsePath, raw, new UTF8Encoding(false));
return new DrawingAssemblyAiResponse("error", raw, "", rawResponsePath);
}
File.WriteAllText(rawResponsePath, raw, new UTF8Encoding(false));
if (!response.IsSuccessStatusCode)
return new DrawingAssemblyAiResponse("error", $"OpenAI API returned {(int)response.StatusCode}: {response.ReasonPhrase}", "", rawResponsePath);
return new DrawingAssemblyAiResponse("ok", "AI response generated.", ExtractResponseText(raw), rawResponsePath);
}
static List<object> BuildResponsesContent(string prompt, IReadOnlyList<MechanicalEvidenceImage> images)
{
var content = new List<object> { new { type = "input_text", text = prompt } };
foreach (var image in images)
{
var dataUrl = BuildImageDataUrl(image);
if (!string.IsNullOrWhiteSpace(dataUrl))
content.Add(new { type = "input_image", image_url = dataUrl });
}
return content;
}
static List<object> BuildChatContent(string prompt, IReadOnlyList<MechanicalEvidenceImage> images)
{
var content = new List<object> { new { type = "text", text = prompt } };
foreach (var image in images)
{
var dataUrl = BuildImageDataUrl(image);
if (!string.IsNullOrWhiteSpace(dataUrl))
content.Add(new { type = "image_url", image_url = new { url = dataUrl } });
}
return content;
}
static string BuildImageDataUrl(MechanicalEvidenceImage image)
{
if (!File.Exists(image.FilePath))
return "";
var contentType = string.IsNullOrWhiteSpace(image.ContentType) ? "image/png" : image.ContentType;
return $"data:{contentType};base64,{Convert.ToBase64String(File.ReadAllBytes(image.FilePath))}";
}
static string ExtractResponseText(string raw)
{
try
{
using var doc = JsonDocument.Parse(raw);
var root = doc.RootElement;
if (root.TryGetProperty("output_text", out var outputText) && outputText.ValueKind == JsonValueKind.String)
return outputText.GetString() ?? "";
if (root.TryGetProperty("choices", out var choices) &&
choices.ValueKind == JsonValueKind.Array &&
choices.GetArrayLength() > 0)
{
var first = choices[0];
if (first.TryGetProperty("message", out var message) &&
message.TryGetProperty("content", out var content))
return ExtractContentText(content);
}
if (root.TryGetProperty("output", out var output) && output.ValueKind == JsonValueKind.Array)
{
var parts = new List<string>();
foreach (var item in output.EnumerateArray())
{
if (!item.TryGetProperty("content", out var content) || content.ValueKind != JsonValueKind.Array)
continue;
foreach (var contentItem in content.EnumerateArray())
{
if (contentItem.TryGetProperty("text", out var text) && text.ValueKind == JsonValueKind.String)
parts.Add(text.GetString() ?? "");
}
}
return string.Join("\n", parts).Trim();
}
}
catch
{
// Fall back to raw text.
}
return raw;
}
static string ExtractContentText(JsonElement content)
{
if (content.ValueKind == JsonValueKind.String)
return content.GetString() ?? "";
if (content.ValueKind != JsonValueKind.Array)
return content.ToString();
var parts = new List<string>();
foreach (var item in content.EnumerateArray())
{
if (item.TryGetProperty("text", out var text) && text.ValueKind == JsonValueKind.String)
parts.Add(text.GetString() ?? "");
}
return string.Join("\n", parts).Trim();
}
}
sealed record DrawingOwnershipSample(string ComponentName, string RawOwner, double XMm, double YMm);
sealed record DrawingReferencePoint(string Kind, double XMm, double YMm, double PenaltyMm);
sealed record DrawingPoint(double X, double Y);
sealed record DrawingGeometryEntity(
string Handle,
string ObjectName,
string Layer,
DrawingPoint? Start,
DrawingPoint? End,
DrawingPoint? Center,
double? Radius,
DwgEntityBounds Bounds);
sealed record DrawingGeometryScore(DrawingGeometryEntity Entity, double DistanceMm, string MatchKind);
sealed record BoundaryProbePoint(string Side, DrawingPoint Point);
sealed record DrawingOwnershipNearest(DrawingOwnershipSample Sample, double DistanceMm);
sealed record DrawingBoundaryComponentPair(
string ComponentA,
string ComponentB,
string GeometryHandle,
string ReferencePointKind,
double MaxProbeDistanceMm,
string Confidence);
sealed record DrawingOwnershipData(JsonObject Summary, List<DrawingOwnershipSample> Samples);
sealed class DrawingAssemblyAnalysisRequest
{
public string ViewId { get; set; } = "";
public string WorkDir { get; set; } = "";
public string StudentViewImage { get; set; } = "";
public string OwnershipImage { get; set; } = "";
public string StudentDwgPath { get; set; } = "";
public string DwgExtractionDir { get; set; } = "";
public string MechanicalContextDir { get; set; } = "";
public string OutputDir { get; set; } = "";
public string AiModel { get; set; } = "";
public string AiTextModel { get; set; } = "";
public string DwgPath { get; set; } = "";
public string DrawingPath { get; set; } = "";
}
sealed class DrawingAssemblyFullRunRequest
{
public string StudentDwgPath { get; set; } = "";
public string ModelPath { get; set; } = "";
public string OutputDir { get; set; } = "";
public string MechanicalContextDir { get; set; } = "";
public bool Visible { get; set; } = true;
public int MaxEntities { get; set; } = 100000;
public double OwnershipStepMm { get; set; } = 1.0;
public int HoverDelayMs { get; set; } = 80;
public int ZoomFitDelayMs { get; set; } = 500;
public string AiModel { get; set; } = "";
public string AiTextModel { get; set; } = "";
}
sealed class DrawingToleranceContextRequest
{
public string StudentDwgPath { get; set; } = "";
public string DwgExtractionDir { get; set; } = "";
public string MechanicalContextDir { get; set; } = "";
public string OwnershipContextDir { get; set; } = "";
public string OutputDir { get; set; } = "";
}
sealed class DrawingToleranceAssessmentRunRequest
{
public string StudentDwgPath { get; set; } = "";
public string DwgExtractionDir { get; set; } = "";
public string MechanicalContextDir { get; set; } = "";
public string OwnershipContextDir { get; set; } = "";
public string OutputDir { get; set; } = "";
public bool UseAi { get; set; }
public string AiTextModel { get; set; } = "";
}
sealed class DrawingDiagnosticUsage
{
public string Target { get; set; } = "";
public string FlowBoundaryDocPath { get; set; } = "";
public string FlowBoundaryMarkdown { get; set; } = "";
public string DrawingDiagnosticReadmePath { get; set; } = "";
public string DrawingDiagnosticReadmeMarkdown { get; set; } = "";
public string AssemblyToleranceFlowPath { get; set; } = "";
public string AssemblyToleranceFlowMarkdown { get; set; } = "";
public List<string> SupportedStages { get; set; } = [];
public string BackendRole { get; set; } = "";
}
sealed class DrawingPartFormatRunRequest
{
public string StudentDwgPath { get; set; } = "";
public string OutputDir { get; set; } = "";
public string ProgId { get; set; } = "";
public bool Visible { get; set; } = true;
public int MaxEntities { get; set; } = 100000;
}
sealed class DrawingAssemblyViewInput
{
public string Schema { get; set; } = "";
public string Scope { get; set; } = "";
public string ViewId { get; set; } = "";
public string StudentViewImage { get; set; } = "";
public string OwnershipImage { get; set; } = "";
public string StudentDwgPath { get; set; } = "";
public string WorkDir { get; set; } = "";
public string DwgExtractionDir { get; set; } = "";
public string MechanicalContextDir { get; set; } = "";
public string OutputDir { get; set; } = "";
public string ProgId { get; set; } = "";
public List<string> Notes { get; set; } = [];
}
sealed class DrawingAssemblyAnalysisResult
{
public bool Ok { get; set; }
public string Stage { get; set; } = "";
public string Message { get; set; } = "";
public string OutputDir { get; set; } = "";
public string ViewId { get; set; } = "";
public string StudentViewImage { get; set; } = "";
public string OwnershipImage { get; set; } = "";
public string ProbePath { get; set; } = "";
public string SupplementalFactsPath { get; set; } = "";
public string CandidateIssuesPath { get; set; } = "";
public string RetrievedRulesPath { get; set; } = "";
public string FinalReportPath { get; set; } = "";
public string FinalReportMarkdown { get; set; } = "";
public string PipelineManifestPath { get; set; } = "";
public string StudentDwgPath { get; set; } = "";
public string ModelPath { get; set; } = "";
public List<DrawingAssemblyFormatIssue> FormatIssues { get; set; } = [];
public List<DrawingAssemblyArtifact> Artifacts { get; set; } = [];
}
sealed class DrawingAssemblyFormatIssue
{
public string Id { get; set; } = "";
public string LineClass { get; set; } = "";
public string Label { get; set; } = "";
public string ExpectedStyle { get; set; } = "";
public string CurrentStyle { get; set; } = "";
public string Reason { get; set; } = "";
public string Confidence { get; set; } = "";
public DrawingViewBounds Bounds { get; set; }
public string EvidenceImagePath { get; set; } = "";
public string EvidenceImageUrl { get; set; } = "";
}
sealed class DrawingAssemblyArtifact
{
public string Name { get; set; } = "";
public string FilePath { get; set; } = "";
public bool Exists { get; set; }
public long Length { get; set; }
public DateTimeOffset? LastModified { get; set; }
}
sealed class DrawingAssemblyCommandResult
{
public string Stage { get; set; } = "";
public string ToolPath { get; set; } = "";
public string FileName { get; set; } = "";
public List<string> Arguments { get; set; } = [];
public int ExitCode { get; set; }
public string StandardOutput { get; set; } = "";
public string StandardError { get; set; } = "";
public DateTimeOffset StartedAt { get; set; }
public DateTimeOffset CompletedAt { get; set; }
}
readonly record struct DrawingViewBounds(double MinX, double MinY, double MaxX, double MaxY)
{
public double CenterX => (MinX + MaxX) / 2.0;
public double CenterY => (MinY + MaxY) / 2.0;
public double Width => MaxX - MinX;
public double Height => MaxY - MinY;
public string ToCliString() =>
string.Join(",", new[]
{
MinX.ToString(System.Globalization.CultureInfo.InvariantCulture),
MinY.ToString(System.Globalization.CultureInfo.InvariantCulture),
MaxX.ToString(System.Globalization.CultureInfo.InvariantCulture),
MaxY.ToString(System.Globalization.CultureInfo.InvariantCulture)
});
}
sealed record DrawingViewRegion(string Id, string Kind, string Label, DrawingViewBounds Bounds);
sealed record DrawingAssemblyAiResponse(string Status, string Message, string Text, string RawResponsePath);
sealed record DrawingAssemblyFinalResult
{
public string Status { get; init; } = "";
public string Message { get; init; } = "";
public string ReportPath { get; init; } = "";
}
sealed record KnowledgeHit(string SourceFile, int Score, string Text);
sealed record DwgLineUse
{
public string Source { get; init; } = "";
public int Index { get; init; }
public string ObjectName { get; init; } = "";
public string Kind { get; init; } = "";
public string Handle { get; init; } = "";
public List<string> SourceHandles { get; init; } = [];
public string Layer { get; init; } = "";
public string Linetype { get; init; } = "";
public int? Lineweight { get; init; }
public string StyleKey { get; init; } = "";
public string OwnershipText { get; init; } = "";
public string RuleReason { get; init; } = "";
public double StartX { get; init; }
public double StartY { get; init; }
public double EndX { get; init; }
public double EndY { get; init; }
public double CenterX { get; init; }
public double CenterY { get; init; }
public bool HasExplicitCenter { get; init; }
public double Length { get; init; }
public double AngleDeg { get; init; }
public double NormalOffset { get; init; }
public DwgEntityBounds Bounds { get; init; } = DwgEntityBounds.Empty;
public string StableKey =>
!string.IsNullOrWhiteSpace(Handle)
? Handle
: SourceHandles.Count > 0
? string.Join("|", SourceHandles)
: $"{Source}:{Index}:{Kind}:{Layer}:{Linetype}:{StartX:0.###},{StartY:0.###}-{EndX:0.###},{EndY:0.###}";
public string StyleText => string.Join(" ", new[]
{
Source,
ObjectName,
Kind,
Handle,
Layer,
Linetype,
StyleKey,
OwnershipText,
RuleReason
}.Where(text => !string.IsNullOrWhiteSpace(text)));
public JsonObject ToJson() => new()
{
["source"] = Source,
["index"] = Index,
["object_name"] = ObjectName,
["kind"] = Kind,
["handle"] = Handle,
["source_handles"] = new JsonArray(SourceHandles.Select(handle => JsonValue.Create(handle)).ToArray<JsonNode?>()),
["layer"] = Layer,
["linetype"] = Linetype,
["lineweight"] = Lineweight,
["style_key"] = StyleKey,
["ownership_text"] = OwnershipText,
["rule_reason"] = RuleReason,
["geometry"] = GeometryJson()
};
public JsonObject GeometryJson() => new()
{
["start"] = new JsonArray(JsonValue.Create(StartX), JsonValue.Create(StartY)),
["end"] = new JsonArray(JsonValue.Create(EndX), JsonValue.Create(EndY)),
["center"] = new JsonArray(JsonValue.Create(CenterX), JsonValue.Create(CenterY)),
["length"] = Length,
["angle_deg"] = AngleDeg,
["bbox"] = Bounds.ToJson()
};
public static DwgLineUse? FromElement(JsonElement element, string source, int index, string ruleReason = "")
{
var objectName = ReadElementString(element, "object_name", "objectName");
var kind = FirstNonEmptyLocal(ReadElementString(element, "kind"), InferKind(objectName));
var handle = ReadElementString(element, "handle");
var sourceHandles = ReadElementStringList(element, "source_handles", "sourceHandles");
if (sourceHandles.Count == 0 && !string.IsNullOrWhiteSpace(handle))
sourceHandles.Add(handle);
var start = ReadPoint(element, "start");
var end = ReadPoint(element, "end");
var bounds = ReadBounds(element);
if (!bounds.IsValid && start.Count >= 2 && end.Count >= 2)
bounds = DwgEntityBounds.FromPoints(start[0], start[1], end[0], end[1]);
var hasLinePoints = start.Count >= 2 && end.Count >= 2;
var dx = hasLinePoints ? end[0] - start[0] : 0.0;
var dy = hasLinePoints ? end[1] - start[1] : 0.0;
var length = ReadElementDouble(element, "length") ?? (hasLinePoints ? Math.Sqrt(dx * dx + dy * dy) : 0.0);
var angle = ReadElementDouble(element, "angle_deg", "angleDeg") ?? (hasLinePoints ? NormalizeAngleDeg(Math.Atan2(dy, dx) * 180.0 / Math.PI) : 0.0);
var explicitCenter = ReadPoint(element, "center");
var hasExplicitCenter = explicitCenter.Count >= 2;
var centerX = hasLinePoints ? (start[0] + end[0]) / 2.0 : hasExplicitCenter ? explicitCenter[0] : bounds.CenterX;
var centerY = hasLinePoints ? (start[1] + end[1]) / 2.0 : hasExplicitCenter ? explicitCenter[1] : bounds.CenterY;
var rad = angle * Math.PI / 180.0;
var nx = -Math.Sin(rad);
var ny = Math.Cos(rad);
return new DwgLineUse
{
Source = source,
Index = index,
ObjectName = objectName,
Kind = kind,
Handle = handle,
SourceHandles = sourceHandles,
Layer = ReadElementString(element, "layer"),
Linetype = ReadElementString(element, "linetype", "line_type", "lineType"),
Lineweight = ReadElementInt(element, "lineweight", "line_weight", "lineWeight"),
StyleKey = ReadElementString(element, "style_key", "styleKey"),
OwnershipText = string.Join(" ", new[]
{
ReadElementString(element, "owner", "owner_id", "ownerId", "ownership", "ownership_id", "ownershipId"),
ReadElementString(element, "component", "component_id", "componentId", "component_name", "componentName"),
ReadElementString(element, "part", "part_id", "partId", "part_name", "partName"),
ReadElementString(element, "feature", "feature_id", "featureId", "feature_name", "featureName"),
ReadElementString(element, "semantic_role", "semanticRole", "role", "category", "function")
}.Where(text => !string.IsNullOrWhiteSpace(text))),
RuleReason = ruleReason,
StartX = hasLinePoints ? start[0] : bounds.CenterX,
StartY = hasLinePoints ? start[1] : bounds.CenterY,
EndX = hasLinePoints ? end[0] : bounds.CenterX,
EndY = hasLinePoints ? end[1] : bounds.CenterY,
CenterX = centerX,
CenterY = centerY,
HasExplicitCenter = hasExplicitCenter,
Length = length,
AngleDeg = angle,
NormalOffset = centerX * nx + centerY * ny,
Bounds = bounds
};
}
static string InferKind(string objectName)
{
if (objectName.Contains("Hatch", StringComparison.OrdinalIgnoreCase))
return "hatch";
if (objectName.Contains("Dimension", StringComparison.OrdinalIgnoreCase))
return "dimension";
if (objectName.Contains("Leader", StringComparison.OrdinalIgnoreCase))
return "leader";
if (objectName.Contains("Line", StringComparison.OrdinalIgnoreCase))
return "line";
if (objectName.Contains("Circle", StringComparison.OrdinalIgnoreCase))
return "circle";
if (objectName.Contains("Arc", StringComparison.OrdinalIgnoreCase))
return "arc";
if (objectName.Contains("Spline", StringComparison.OrdinalIgnoreCase))
return "spline";
return "";
}
static string FirstNonEmptyLocal(params string[] values)
{
foreach (var value in values)
{
if (!string.IsNullOrWhiteSpace(value))
return value.Trim();
}
return "";
}
static double NormalizeAngleDeg(double angle)
{
angle %= 180.0;
if (angle < 0)
angle += 180.0;
return angle;
}
static string ReadElementString(JsonElement element, params string[] names)
{
if (!TryGetProperty(element, names, out var value) || value.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined)
return "";
return value.ValueKind == JsonValueKind.String ? value.GetString() ?? "" : value.ToString();
}
static int? ReadElementInt(JsonElement element, params string[] names)
{
if (!TryGetProperty(element, names, out var value) || value.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined)
return null;
if (value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var number))
return number;
return int.TryParse(value.ToString(), out var parsed) ? parsed : null;
}
static double? ReadElementDouble(JsonElement element, params string[] names)
{
if (!TryGetProperty(element, names, out var value) || value.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined)
return null;
if (value.ValueKind == JsonValueKind.Number && value.TryGetDouble(out var number))
return number;
return double.TryParse(value.ToString(), System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var parsed)
? parsed
: null;
}
static List<string> ReadElementStringList(JsonElement element, params string[] names)
{
if (!TryGetProperty(element, names, out var value) || value.ValueKind != JsonValueKind.Array)
return [];
return value.EnumerateArray()
.Select(item => item.ValueKind == JsonValueKind.String ? item.GetString() ?? "" : item.ToString())
.Where(text => !string.IsNullOrWhiteSpace(text))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
}
static List<double> ReadPoint(JsonElement element, string name)
{
if (!TryGetProperty(element, [name], out var value) || value.ValueKind != JsonValueKind.Array)
return [];
var result = new List<double>();
foreach (var item in value.EnumerateArray())
{
if (item.ValueKind == JsonValueKind.Number && item.TryGetDouble(out var number))
result.Add(number);
}
return result;
}
static DwgEntityBounds ReadBounds(JsonElement element)
{
if (TryGetProperty(element, ["bbox"], out var bbox) && bbox.ValueKind == JsonValueKind.Object)
return ReadBoundsObject(bbox);
return ReadBoundsObject(element);
}
static DwgEntityBounds ReadBoundsObject(JsonElement element)
{
var minX = ReadElementDouble(element, "min_x", "minX");
var minY = ReadElementDouble(element, "min_y", "minY");
var maxX = ReadElementDouble(element, "max_x", "maxX");
var maxY = ReadElementDouble(element, "max_y", "maxY");
return minX != null && minY != null && maxX != null && maxY != null
? new DwgEntityBounds(minX.Value, minY.Value, maxX.Value, maxY.Value)
: DwgEntityBounds.Empty;
}
static bool TryGetProperty(JsonElement element, string[] names, out JsonElement value)
{
if (element.ValueKind == JsonValueKind.Object)
{
foreach (var prop in element.EnumerateObject())
{
if (names.Any(name => string.Equals(prop.Name, name, StringComparison.OrdinalIgnoreCase)))
{
value = prop.Value;
return true;
}
}
}
value = default;
return false;
}
}
readonly record struct DwgEntityBounds(double MinX, double MinY, double MaxX, double MaxY)
{
public static DwgEntityBounds Empty => new(double.NaN, double.NaN, double.NaN, double.NaN);
public bool IsValid => !double.IsNaN(MinX) && !double.IsNaN(MinY) && !double.IsNaN(MaxX) && !double.IsNaN(MaxY);
public double CenterX => IsValid ? (MinX + MaxX) / 2.0 : 0.0;
public double CenterY => IsValid ? (MinY + MaxY) / 2.0 : 0.0;
public double Width => IsValid ? MaxX - MinX : 0.0;
public double Height => IsValid ? MaxY - MinY : 0.0;
public DwgEntityBounds Expanded(double amount) =>
IsValid ? new DwgEntityBounds(MinX - amount, MinY - amount, MaxX + amount, MaxY + amount) : this;
public bool Intersects(DwgEntityBounds other) =>
IsValid &&
other.IsValid &&
MaxX >= other.MinX &&
other.MaxX >= MinX &&
MaxY >= other.MinY &&
other.MaxY >= MinY;
public JsonObject ToJson() => IsValid
? new JsonObject
{
["min_x"] = MinX,
["min_y"] = MinY,
["max_x"] = MaxX,
["max_y"] = MaxY
}
: new JsonObject();
public static DwgEntityBounds FromPoints(double ax, double ay, double bx, double by) => new(
Math.Min(ax, bx),
Math.Min(ay, by),
Math.Max(ax, bx),
Math.Max(ay, by));
}
sealed record SectionHatchGroup(
string Id,
double AngleBucketDeg,
List<DwgLineUse> Lines,
DwgEntityBounds Bounds)
{
public DwgEntityBounds Expanded(double amount) => Bounds.Expanded(amount);
public static SectionHatchGroup FromLines(string id, double angleBucketDeg, IReadOnlyList<DwgLineUse> lines)
{
var valid = lines.Select(line => line.Bounds).Where(bounds => bounds.IsValid).ToList();
var bounds = valid.Count == 0
? DwgEntityBounds.Empty
: new DwgEntityBounds(valid.Min(item => item.MinX), valid.Min(item => item.MinY), valid.Max(item => item.MaxX), valid.Max(item => item.MaxY));
return new SectionHatchGroup(id, angleBucketDeg, lines.ToList(), bounds);
}
}
sealed record ThreadLinePair(
DwgLineUse LineA,
DwgLineUse LineB,
double AngleDeg,
double AxisOffset,
double Spacing,
double T0,
double T1);