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

2524 lines
106 KiB
C#

using System.Diagnostics;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Serialization;
Console.OutputEncoding = Encoding.UTF8;
if (args.Length == 0 || IsHelp(args[0]))
{
PrintUsage();
return 1;
}
try
{
return args[0].Trim().ToLowerInvariant() switch
{
"verify" => Verify(args.Skip(1).ToArray()),
_ => Fail($"Unknown command: {args[0]}")
};
}
catch (Exception ex)
{
Console.Error.WriteLine(ex.Message);
return 1;
}
static int Verify(string[] args)
{
var parsed = Args.Parse(args);
var modelInput = parsed.Required("--model");
var rulePath = parsed.Required("--rules");
var outBase = parsed.Optional("--out") ?? Path.Combine("runtime", "diagnostics", "model_diagnostic_result");
var root = LocateRepoRoot();
var evidencePaths = EvidenceInputResolver.Resolve(root, modelInput);
var package = JsonSerializer.Deserialize<RuleTemplatePackage>(File.ReadAllText(rulePath, Encoding.UTF8), JsonOptions.Default)
?? throw new InvalidOperationException("Cannot parse rule template package.");
var modelEvidence = ModelEvidenceNormalizer.Normalize(evidencePaths);
var result = DiagnosticPipeline.Run(package, modelEvidence, modelInput, evidencePaths, rulePath);
var fullOutBase = Path.GetFullPath(outBase);
Directory.CreateDirectory(Path.GetDirectoryName(fullOutBase) ?? ".");
File.WriteAllText(fullOutBase + ".json", JsonSerializer.Serialize(result, JsonOptions.Default), new UTF8Encoding(false));
File.WriteAllText(fullOutBase + ".md", MarkdownReport.Write(result), new UTF8Encoding(false));
Console.WriteLine(JsonSerializer.Serialize(result, JsonOptions.Default));
return result.RuleResults.Any(r => r.Decision == "problem") ? 4 : 0;
}
static bool IsHelp(string arg) => arg is "-h" or "--help" or "/?" or "help";
static int Fail(string message)
{
Console.Error.WriteLine(message);
PrintUsage();
return 1;
}
static void PrintUsage()
{
Console.WriteLine("""
ModelDiagnosticVerifier
Purpose:
Verify SolidWorks model evidence against rule packages generated by RuleTemplateGenerator.
Usage:
ModelDiagnosticVerifier verify --model <assembly.SLDASM|evidence.json> --rules <rule_template.json> [--out <outputBase>]
Flow:
model -> raw B-rep/structural evidence -> scene candidates -> rule applicability
-> model 2D section structure instances -> image structure template matching
-> real geometry/contact evidence validation -> pass/problem/needs_review
""");
}
static string LocateRepoRoot()
{
var dir = new DirectoryInfo(Environment.CurrentDirectory);
while (dir != null)
{
if (File.Exists(Path.Combine(dir.FullName, "project_paths.json")) &&
Directory.Exists(Path.Combine(dir.FullName, "tools")))
return dir.FullName;
dir = dir.Parent;
}
return Environment.CurrentDirectory;
}
static class EvidenceInputResolver
{
public static List<string> Resolve(string root, string modelInput)
{
var full = Path.GetFullPath(modelInput);
if (!File.Exists(full))
throw new FileNotFoundException($"Model/evidence input not found: {full}");
var ext = Path.GetExtension(full).ToLowerInvariant();
if (ext == ".json")
return [full];
if (ext is ".sldasm" or ".sldprt")
return RunModelEvidenceExtractors(root, full);
throw new InvalidOperationException($"Unsupported model input extension: {ext}");
}
static List<string> RunModelEvidenceExtractors(string root, string modelPath)
{
var evidencePaths = new List<string>();
var section = TryRunProject(
root,
Path.Combine(root, "tools", "model-diagnostics", "SectionBrepExtractor", "SectionBrepExtractor.csproj"),
modelPath,
Path.Combine(root, "tools", "model-diagnostics", "SectionBrepExtractor", "output", "section_brep_report.json"),
"SectionBrepExtractor");
if (section != null)
evidencePaths.Add(section);
var structural = TryRunProject(
root,
Path.Combine(root, "tools", "model-diagnostics", "StructuralFaultProbe", "StructuralFaultProbe.csproj"),
modelPath,
Path.Combine(root, "runtime", "structural_fault_probe", "structural_fault_evidence.json"),
"StructuralFaultProbe");
if (structural != null)
evidencePaths.Add(structural);
if (evidencePaths.Count == 0)
throw new InvalidOperationException("No model evidence extractor produced a usable JSON file.");
return evidencePaths;
}
static string? TryRunProject(string root, string projectPath, string modelPath, string expectedOutput, string name)
{
if (!File.Exists(projectPath))
return null;
var psi = new ProcessStartInfo("dotnet")
{
WorkingDirectory = root,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
psi.ArgumentList.Add("run");
psi.ArgumentList.Add("--project");
psi.ArgumentList.Add(projectPath);
psi.ArgumentList.Add("-c");
psi.ArgumentList.Add("Release");
psi.ArgumentList.Add("--");
psi.ArgumentList.Add(modelPath);
using var process = Process.Start(psi) ?? throw new InvalidOperationException($"Failed to start {name}.");
var stdout = process.StandardOutput.ReadToEnd();
var stderr = process.StandardError.ReadToEnd();
process.WaitForExit();
if (process.ExitCode != 0)
{
Console.Error.WriteLine($"{name} failed with exit={process.ExitCode}");
Console.Error.WriteLine(stdout);
Console.Error.WriteLine(stderr);
return null;
}
return File.Exists(expectedOutput) ? expectedOutput : null;
}
}
static class ModelEvidenceNormalizer
{
public static ModelEvidence Normalize(List<string> sourcePaths)
{
var evidence = new ModelEvidence
{
Schema = "model_evidence_v1",
SourcePaths = sourcePaths.Select(Path.GetFullPath).ToList()
};
foreach (var sourcePath in sourcePaths)
{
using var json = JsonDocument.Parse(File.ReadAllText(sourcePath, Encoding.UTF8));
var root = json.RootElement;
if (root.ValueKind == JsonValueKind.Array)
{
foreach (var assembly in root.EnumerateArray())
ReadStructuralAssemblyReport(assembly, evidence);
}
else
{
ReadStructuralAssemblyReport(root, evidence);
ReadSectionBrepReport(root, evidence);
}
}
SectionStructureSynthesizer.Synthesize(evidence);
SceneRecognizer.Recognize(evidence);
PortFeatureSynthesizer.Synthesize(evidence);
DisassemblyAccessDefinitionFilter.Apply(evidence);
return evidence;
}
static void ReadStructuralAssemblyReport(JsonElement assembly, ModelEvidence evidence)
{
if (assembly.ValueKind != JsonValueKind.Object)
return;
if (assembly.TryGetProperty("AssemblyPath", out var assemblyPath) && assemblyPath.ValueKind == JsonValueKind.String)
evidence.ModelPath = assemblyPath.GetString() ?? evidence.ModelPath;
if (TryArray(assembly, "Components", out var components))
{
foreach (var component in components.EnumerateArray())
{
var c = new ComponentEvidence
{
Id = GetString(component, "ComponentName"),
Name = GetString(component, "ComponentName"),
PartName = GetString(component, "PartName"),
Category = NormalizeCategory(GetString(component, "Category")),
Path = GetString(component, "Path")
};
AddOrMergeComponent(evidence, c);
if (TryArray(component, "CylinderFaces", out var cylinderFaces))
ReadComponentCylinderFaces(c, cylinderFaces, evidence);
}
}
if (TryArray(assembly, "ComponentFixations", out var fixations))
{
foreach (var fixation in fixations.EnumerateArray())
{
var componentName = GetString(fixation, "ComponentName");
var category = NormalizeCategory(GetString(fixation, "Category"));
MergeComponentFrame(evidence, componentName, fixation);
AddIfTrue(evidence, GetBool(fixation, "LeftFixed"), "left_axial_limit", componentName, 0.8, "StructuralFaultProbe.ComponentFixations");
AddIfTrue(evidence, GetBool(fixation, "RightFixed"), "right_axial_limit", componentName, 0.8, "StructuralFaultProbe.ComponentFixations");
AddIfTrue(evidence, GetBool(fixation, "FixedBothSides"), "both_sides_axial_limit", componentName, 0.85, "StructuralFaultProbe.ComponentFixations");
if (TryArray(fixation, "Contacts", out var contacts))
{
foreach (var contact in contacts.EnumerateArray())
{
var accepted = GetBool(contact, "Accepted");
var other = GetString(contact, "ComponentName");
var side = NormalizeToken(GetString(contact, "Side"));
var ring = NormalizeToken(GetString(contact, "BearingRing"));
var id = $"{componentName}:{other}:{side}:{ring}:{evidence.Items.Count + 1}";
evidence.Items.Add(new EvidenceItem
{
Id = id,
Type = "axial_contact",
Owner = componentName,
Related = other,
Confidence = accepted ? 0.82 : 0.35,
Source = "StructuralFaultProbe.ComponentFixations.Contacts",
Attributes =
{
["side"] = side,
["ring"] = ring,
["accepted"] = accepted.ToString().ToLowerInvariant(),
["target_category"] = category,
["center_mm"] = FormatArray(GetDoubleArray(contact, "CenterMm")),
["normal"] = FormatArray(GetDoubleArray(contact, "Normal")),
["axial_position_mm"] = FormatDouble(GetDouble(contact, "AxialPositionMm")),
["face_radial_min_mm"] = FormatDouble(GetDouble(contact, "FaceRadialMinMm")),
["face_radial_max_mm"] = FormatDouble(GetDouble(contact, "FaceRadialMaxMm"))
}
});
}
}
}
}
if (TryArray(assembly, "Bearings", out var bearings))
{
foreach (var bearing in bearings.EnumerateArray())
ReadBearingReport(bearing, evidence);
}
}
static void ReadBearingReport(JsonElement bearing, ModelEvidence evidence)
{
var name = GetString(bearing, "ComponentName");
AddIfNotEmpty(evidence, name, "bearing_component", name, 0.85, "StructuralFaultProbe.Bearings");
if (bearing.TryGetProperty("RuleInterpretation", out var rule) && rule.ValueKind == JsonValueKind.Object)
{
AddIfTrue(evidence, GetBool(rule, "InnerLeft"), "bearing_inner_left_limit", name, 0.84, "StructuralFaultProbe.Bearings");
AddIfTrue(evidence, GetBool(rule, "InnerRight"), "bearing_inner_right_limit", name, 0.84, "StructuralFaultProbe.Bearings");
AddIfTrue(evidence, GetBool(rule, "OuterLeft"), "bearing_outer_left_limit", name, 0.84, "StructuralFaultProbe.Bearings");
AddIfTrue(evidence, GetBool(rule, "OuterRight"), "bearing_outer_right_limit", name, 0.84, "StructuralFaultProbe.Bearings");
}
}
static void ReadSectionBrepReport(JsonElement report, ModelEvidence evidence)
{
ReadMechanicalFeatureGraph(report, evidence);
if (!TryObject(report, "SketchGraph", out var sketch))
return;
var edgeIndex = BuildSketchEdgeIndex(sketch);
if (TryArray(sketch, "Relations", out var relations))
{
foreach (var relation in relations.EnumerateArray())
{
var type = NormalizeToken(GetString(relation, "Type"));
var a = GetString(relation, "A");
var b = GetString(relation, "B");
if (string.IsNullOrWhiteSpace(type))
continue;
AddComponentFromReference(evidence, a);
AddComponentFromReference(evidence, b);
evidence.SectionInstances.AddRelation(type, a, b, 0.86, "SketchGraph.Relations");
if (type == "contact")
evidence.Items.Add(Item("close_fit_contact", "section_graph", $"{a}|{b}", 0.9, "SketchGraph.Relations"));
}
}
if (TryArray(sketch, "Dimensions", out var dimensions))
{
foreach (var dimension in dimensions.EnumerateArray())
ReadSketchDimension(dimension, edgeIndex, evidence);
}
if (TryArray(sketch, "Regions", out var regions))
{
foreach (var region in regions.EnumerateArray())
{
var id = GetString(region, "Id");
var role = NormalizeToken(GetString(region, "Role"));
if (role is "tool_gap" or "gap_region" or "void_region")
{
evidence.SectionInstances.Entities.Add(new StructureEntity { Id = id, Kind = "region", SemanticRole = "void_gap" });
PromoteGapRegionEvidence(id, role, evidence);
}
if (role is "ejector_port" or "port_region")
{
evidence.SectionInstances.Entities.Add(new StructureEntity { Id = id, Kind = "region", SemanticRole = "ejector_or_threaded_hole" });
PromotePortRegionEvidence(id, role, evidence);
}
}
}
GapFeatureSynthesizer.Synthesize(sketch, edgeIndex.Values
.GroupBy(e => e.Id, StringComparer.OrdinalIgnoreCase)
.Select(g => g.First())
.ToList(), evidence);
}
static void ReadMechanicalFeatureGraph(JsonElement report, ModelEvidence evidence)
{
if (!TryObject(report, "FeatureGraph", out var graph) || !TryArray(graph, "Features", out var features))
return;
foreach (var feature in features.EnumerateArray())
{
var id = GetString(feature, "Id");
var type = NormalizeToken(GetString(feature, "Type"));
var component = GetString(feature, "ComponentName");
var source = GetString(feature, "Source");
var confidence = GetDouble(feature, "Confidence");
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(type))
continue;
if (confidence <= 0)
confidence = 0.65;
evidence.SectionInstances.Entities.Add(new StructureEntity
{
Id = id,
Kind = "brep_feature",
SemanticRole = type
});
var faceRefs = ReadStringArray(feature, "FaceRefs").ToList();
evidence.Items.Add(new EvidenceItem
{
Id = $"feature:{id}",
Type = type,
Owner = component,
Related = id,
Confidence = confidence,
Source = string.IsNullOrWhiteSpace(source) ? "FeatureGraph" : $"FeatureGraph.{source}",
Attributes =
{
["feature_id"] = id,
["face_refs"] = string.Join(",", faceRefs)
}
});
foreach (var faceRef in faceRefs)
evidence.SectionInstances.AddRelation("feature_has_face", id, faceRef, confidence, "FeatureGraph.FaceRefs");
foreach (var possible in ReadStringArray(feature, "PossibleFunctions"))
{
var possibleType = NormalizeToken(possible);
if (string.IsNullOrWhiteSpace(possibleType))
continue;
evidence.Items.Add(new EvidenceItem
{
Id = $"feature_possible:{id}:{possibleType}",
Type = possibleType,
Owner = component,
Related = id,
Confidence = Math.Min(confidence, 0.68),
Source = "FeatureGraph.PossibleFunctions",
Attributes =
{
["feature_id"] = id,
["feature_type"] = type
}
});
evidence.SectionInstances.AddRelation(possibleType, id, component, Math.Min(confidence, 0.68), "FeatureGraph.PossibleFunctions");
}
}
}
static Dictionary<string, SketchEdgeInfo> BuildSketchEdgeIndex(JsonElement sketch)
{
var index = new Dictionary<string, SketchEdgeInfo>(StringComparer.OrdinalIgnoreCase);
if (!TryArray(sketch, "Edges", out var edges))
return index;
foreach (var edge in edges.EnumerateArray())
{
var id = GetString(edge, "Id");
if (string.IsNullOrWhiteSpace(id))
continue;
var info = new SketchEdgeInfo
{
Id = id,
Owner = GetString(edge, "Owner"),
Role = NormalizeToken(GetString(edge, "Role")),
Orientation = NormalizeToken(AttributeString(edge, "orientation")),
Length = GetDouble(edge, "Length"),
Start = GetDoubleArray(edge, "Start"),
End = GetDoubleArray(edge, "End")
};
index[id] = info;
if (TryArray(edge, "SourceEdgeIds", out var sourceIds))
{
foreach (var source in sourceIds.EnumerateArray())
{
if (source.ValueKind == JsonValueKind.String)
{
var sourceId = source.GetString();
if (!string.IsNullOrWhiteSpace(sourceId))
{
info.SourceEdgeIds.Add(sourceId);
index[sourceId] = info;
}
}
}
}
}
return index;
}
static void ReadSketchDimension(JsonElement dimension, Dictionary<string, SketchEdgeInfo> edgeIndex, ModelEvidence evidence)
{
var kind = NormalizeToken(GetString(dimension, "Kind"));
var role = NormalizeToken(GetString(dimension, "Role"));
var value = GetDouble(dimension, "Value");
if (kind is "gap_width_h" or "gap_width_candidate" or "minimum_gap_width_candidate" ||
role is "tool_gap_width_candidate" or "tool_insertion_clearance_candidate")
{
if (value >= 2.0)
{
AddEvidenceOnce(evidence, "gap_width_sufficient", "gap_region", "h", 0.76, "SketchGraph.Dimensions");
evidence.SectionInstances.AddRelation("gap_width_sufficient", "gap_region", "h", 0.76, "SketchGraph.Dimensions");
}
return;
}
if (kind is "port_diameter_t" or "port_diameter_candidate" or "ejector_port_diameter_candidate" ||
role is "ejector_port_size_candidate" or "threaded_hole_size_candidate")
{
if (value >= 1.0)
{
AddEvidenceOnce(evidence, "port_diameter_sufficient", "port_region", "t", 0.76, "SketchGraph.Dimensions");
evidence.SectionInstances.AddRelation("port_diameter_sufficient", "port_region", "t", 0.76, "SketchGraph.Dimensions");
}
return;
}
if (kind != "exposed_sleeve_end_height_candidate" && role != "exposed_push_face_candidate")
return;
if (value < 1.0)
return;
if (!TryArray(dimension, "TargetRefs", out var refs))
return;
foreach (var reference in refs.EnumerateArray())
{
if (reference.ValueKind != JsonValueKind.String)
continue;
var id = reference.GetString() ?? "";
if (!edgeIndex.TryGetValue(id, out var edge))
continue;
if (!IsSleeveOwner(edge.Owner))
continue;
evidence.Items.Add(new EvidenceItem
{
Id = $"exposed_axial_push_face:{edge.Id}",
Type = "exposed_axial_push_face",
Owner = edge.Owner,
Related = edge.Id,
Confidence = 0.86,
Source = "SketchGraph.Dimensions",
Attributes =
{
["dimension_kind"] = kind,
["height_mm"] = value.ToString(System.Globalization.CultureInfo.InvariantCulture),
["orientation"] = edge.Orientation
}
});
evidence.Items.Add(new EvidenceItem
{
Id = $"outside_accessible_force_path:{edge.Id}",
Type = "outside_accessible_force_path",
Owner = edge.Owner,
Related = edge.Id,
Confidence = 0.8,
Source = "SketchGraph.Dimensions",
Attributes =
{
["via"] = "exposed_sleeve_end_height_candidate"
}
});
AddEvidenceOnce(evidence, "outside_accessible_push_path", edge.Owner, edge.Id, 0.82, "SketchGraph.Dimensions");
AddEvidenceOnce(evidence, "push_path_reaches_sleeve_push_area", edge.Owner, edge.Id, 0.82, "SketchGraph.Dimensions");
AddEvidenceOnce(evidence, "push_direction_has_removal_component", edge.Owner, edge.Id, 0.78, "SketchGraph.Dimensions");
evidence.SectionInstances.AddRelation("owned_by", "push_face", "sleeve_material", 0.86, "SketchGraph.Dimensions");
evidence.SectionInstances.AddRelation("force_direction_parallel_to_axis", "push_face", "sleeve_axis", 0.78, "SketchGraph.Dimensions");
evidence.SectionInstances.AddRelation("can_push_or_strike", "outside", "push_face", 0.8, "SketchGraph.Dimensions");
evidence.SectionInstances.AddRelation("adjacent_to_outside", "push_face", "outside", 0.86, "SketchGraph.Dimensions");
evidence.SectionInstances.AddRelation("connected_to_outside", "push_access_path", "outside", 0.82, "SketchGraph.Dimensions");
evidence.SectionInstances.AddRelation("reaches", "push_access_path", "sleeve_push_area", 0.82, "SketchGraph.Dimensions");
evidence.SectionInstances.AddRelation("direction_has_component", "push_access_path", "removal_axis", 0.78, "SketchGraph.Dimensions");
}
}
static void PromoteGapRegionEvidence(string regionId, string role, ModelEvidence evidence)
{
bool outside = RegionConnectedToOutside(evidence, regionId);
bool adjacentSleeve = RegionAdjacentToSleeve(evidence, regionId);
bool adjacentEnd = RegionHasRelation(evidence, regionId, ["adjacent_to_sleeve_end", "reaches_sleeve_end", "force_path_reaches"]);
bool toolCanEnter = RegionHasRelation(evidence, regionId, ["tool_can_enter", "tool_access", "tool_insertable"]);
bool widthOk = evidence.Has("gap_width_sufficient", 0.7) || RegionHasRelation(evidence, regionId, ["gap_width_sufficient", "minimum_width_sufficient"]);
if (outside)
{
AddEvidenceOnce(evidence, "outside_connected_gap", regionId, "outside", 0.82, "SketchGraph.Regions");
AddEvidenceOnce(evidence, "outside_connected_void_gap", regionId, "outside", 0.82, "SketchGraph.Regions");
evidence.SectionInstances.AddRelation("connected_to_outside", "gap_region", "outside", 0.82, "SketchGraph.Regions");
}
if (adjacentSleeve)
{
AddEvidenceOnce(evidence, "gap_adjacent_to_sleeve_end", regionId, "sleeve_end", adjacentEnd ? 0.82 : 0.72, "SketchGraph.Regions");
evidence.SectionInstances.AddRelation("adjacent_to", "gap_region", "sleeve_material", 0.78, "SketchGraph.Regions");
evidence.SectionInstances.AddRelation("adjacent_to_sleeve_end", "gap_region", "sleeve_end", adjacentEnd ? 0.82 : 0.72, "SketchGraph.Regions");
}
if (outside && (toolCanEnter || role is "tool_gap" or "gap_region"))
{
AddEvidenceOnce(evidence, "tool_can_enter_gap", regionId, "outside", toolCanEnter ? 0.82 : 0.72, "SketchGraph.Regions");
evidence.SectionInstances.AddRelation("tool_can_enter", "outside", "gap_region", toolCanEnter ? 0.82 : 0.72, "SketchGraph.Regions");
}
if (outside && adjacentSleeve)
{
AddEvidenceOnce(evidence, "gap_force_path_reaches_sleeve", regionId, "sleeve_end", 0.76, "SketchGraph.Regions");
AddEvidenceOnce(evidence, "gap_path_reaches_sleeve_push_area", regionId, "sleeve_push_area", 0.76, "SketchGraph.Regions");
AddEvidenceOnce(evidence, "tool_force_direction_valid", regionId, "removal_axis", 0.72, "SketchGraph.Regions");
evidence.SectionInstances.AddRelation("force_path_reaches", "gap_region", "sleeve_end", 0.76, "SketchGraph.Regions");
evidence.SectionInstances.AddRelation("reaches", "gap_region", "sleeve_push_area", 0.76, "SketchGraph.Regions");
evidence.SectionInstances.AddRelation("direction_has_component", "gap_region", "removal_axis", 0.72, "SketchGraph.Regions");
}
if (widthOk)
{
AddEvidenceOnce(evidence, "gap_width_sufficient", regionId, "h", 0.76, "SketchGraph.Regions");
evidence.SectionInstances.AddRelation("gap_width_sufficient", "gap_region", "h", 0.76, "SketchGraph.Regions");
}
}
static void PromotePortRegionEvidence(string regionId, string role, ModelEvidence evidence)
{
bool outside = RegionConnectedToOutside(evidence, regionId);
bool reachesPushArea = RegionHasRelation(evidence, regionId, ["reaches_sleeve_push_area", "reaches", "push_area_on"]);
bool axisOk = RegionHasRelation(evidence, regionId, ["axis_points_to", "axis_can_push", "port_axis_can_push_sleeve"]);
bool threaded = role == "ejector_port" || RegionHasRelation(evidence, regionId, ["threaded_or_ejector_port", "threaded_hole", "ejector_screw_hole"]);
bool diameterOk = evidence.Has("port_diameter_sufficient", 0.7) || RegionHasRelation(evidence, regionId, ["port_diameter_sufficient", "hole_size_sufficient"]);
if (outside)
{
AddEvidenceOnce(evidence, "outside_connected_port", regionId, "outside", 0.82, "SketchGraph.Regions");
AddEvidenceOnce(evidence, "outside_connected_cylindrical_port", regionId, "outside", 0.82, "SketchGraph.Regions");
evidence.SectionInstances.AddRelation("connected_to_outside", "port_region", "outside", 0.82, "SketchGraph.Regions");
}
if (reachesPushArea)
{
AddEvidenceOnce(evidence, "port_reaches_sleeve_push_area", regionId, "push_area", 0.84, "SketchGraph.Regions");
AddEvidenceOnce(evidence, "port_axis_intersects_sleeve_push_area", regionId, "sleeve_push_area", 0.78, "SketchGraph.Regions");
evidence.SectionInstances.AddRelation("reaches", "port_region", "push_area", 0.84, "SketchGraph.Regions");
evidence.SectionInstances.AddRelation("push_area_on", "push_area", "sleeve_material", 0.84, "SketchGraph.Regions");
evidence.SectionInstances.AddRelation("axis_intersects", "port_axis", "sleeve_push_area", 0.78, "SketchGraph.Regions");
}
if (axisOk || reachesPushArea)
{
AddEvidenceOnce(evidence, "port_axis_can_push_sleeve", regionId, "push_area", axisOk ? 0.82 : 0.72, "SketchGraph.Regions");
AddEvidenceOnce(evidence, "port_push_direction_valid", regionId, "removal_axis", axisOk ? 0.82 : 0.72, "SketchGraph.Regions");
evidence.SectionInstances.AddRelation("axis_points_to", "port_axis", "push_area", axisOk ? 0.82 : 0.72, "SketchGraph.Regions");
evidence.SectionInstances.AddRelation("screw_can_push", "port_region", "push_area", axisOk ? 0.82 : 0.72, "SketchGraph.Regions");
evidence.SectionInstances.AddRelation("direction_has_component", "port_axis", "removal_axis", axisOk ? 0.82 : 0.72, "SketchGraph.Regions");
}
if (threaded)
{
AddEvidenceOnce(evidence, "threaded_or_ejector_port", regionId, "t", role == "ejector_port" ? 0.84 : 0.74, "SketchGraph.Regions");
evidence.SectionInstances.AddRelation("threaded_or_ejector_port", "port_region", "t", role == "ejector_port" ? 0.84 : 0.74, "SketchGraph.Regions");
evidence.SectionInstances.AddRelation("owned_by", "port_region", "host_material", 0.72, "SketchGraph.Regions");
}
if (diameterOk)
{
AddEvidenceOnce(evidence, "port_diameter_sufficient", regionId, "t", 0.76, "SketchGraph.Regions");
evidence.SectionInstances.AddRelation("port_diameter_sufficient", "port_region", "t", 0.76, "SketchGraph.Regions");
}
}
static void AddIfTrue(ModelEvidence evidence, bool condition, string type, string owner, double confidence, string source)
{
if (condition)
evidence.Items.Add(Item(type, owner, "", confidence, source));
}
static void AddIfNotEmpty(ModelEvidence evidence, string value, string type, string owner, double confidence, string source)
{
if (!string.IsNullOrWhiteSpace(value))
evidence.Items.Add(Item(type, owner, "", confidence, source));
}
static EvidenceItem Item(string type, string owner, string related, double confidence, string source)
{
var rawId = $"{type}:{owner}:{related}:{Guid.NewGuid():N}";
return new EvidenceItem
{
Id = rawId[..Math.Min(64, rawId.Length)],
Type = NormalizeToken(type),
Owner = owner,
Related = related,
Confidence = confidence,
Source = source
};
}
static void AddEvidenceOnce(ModelEvidence evidence, string type, string owner, string related, double confidence, string source)
{
var normalized = NormalizeToken(type);
if (evidence.Items.Any(i =>
i.Type.Equals(normalized, StringComparison.OrdinalIgnoreCase) &&
i.Owner.Equals(owner, StringComparison.OrdinalIgnoreCase) &&
i.Related.Equals(related, StringComparison.OrdinalIgnoreCase)))
return;
evidence.Items.Add(new EvidenceItem
{
Id = $"{normalized}:{owner}:{related}:promoted"[..Math.Min(64, $"{normalized}:{owner}:{related}:promoted".Length)],
Type = normalized,
Owner = owner,
Related = related,
Confidence = confidence,
Source = source
});
}
static bool RegionConnectedToOutside(ModelEvidence evidence, string regionId)
{
return evidence.SectionInstances.Relations.Any(r =>
(r.Type is "connected_to_outside" or "region_connected_to_region" or "adjacent_to_outside") &&
RelationTouches(r, regionId) &&
(IsOutsideReference(r.A) || IsOutsideReference(r.B) || r.Type == "connected_to_outside"));
}
static bool RegionAdjacentToSleeve(ModelEvidence evidence, string regionId)
{
return evidence.SectionInstances.Relations.Any(r =>
(r.Type is "adjacent_to_sleeve_region" or "adjacent_to" or "adjacent_to_sleeve_end") &&
RelationTouches(r, regionId) &&
(IsSleeveReference(r.A) || IsSleeveReference(r.B) || r.Type is "adjacent_to_sleeve_region" or "adjacent_to_sleeve_end"));
}
static bool RegionHasRelation(ModelEvidence evidence, string regionId, string[] relationTypes)
{
var normalized = relationTypes.Select(NormalizeToken).ToHashSet(StringComparer.OrdinalIgnoreCase);
return evidence.SectionInstances.Relations.Any(r => normalized.Contains(r.Type) && RelationTouches(r, regionId));
}
static bool RelationTouches(StructureRelation relation, string id)
{
return relation.A.Equals(id, StringComparison.OrdinalIgnoreCase) ||
relation.B.Equals(id, StringComparison.OrdinalIgnoreCase);
}
static bool IsOutsideReference(string value)
{
return NormalizeToken(value) is "outside" or "region_outside" or "outside_air" or "environment";
}
static bool IsSleeveReference(string value)
{
return NormalizeToken(value) is "sleeve" or "sleeve_material" or "sleeve_end" or "sleeve_push_area" ||
IsSleeveOwner(value);
}
static void AddComponentFromReference(ModelEvidence evidence, string reference)
{
var component = ExtractComponentName(reference);
if (string.IsNullOrWhiteSpace(component) || evidence.Components.Any(c => c.Id == component))
return;
AddOrMergeComponent(evidence, new ComponentEvidence
{
Id = component,
Name = component,
Category = GuessCategory(component)
});
}
static void ReadComponentCylinderFaces(ComponentEvidence component, JsonElement cylinderFaces, ModelEvidence evidence)
{
foreach (var face in cylinderFaces.EnumerateArray())
{
var radius = GetDouble(face, "RadiusMm");
var axis = GetDoubleArray(face, "Axis");
var axisPoint = GetDoubleArray(face, "AxisPointMm");
if (radius <= 0 || axis.Length != 3 || axisPoint.Length != 3)
continue;
var faceIndex = GetString(face, "FaceIndex");
var id = $"cylindrical_face:{component.Id}:{faceIndex}:{radius:0.###}";
evidence.Items.Add(new EvidenceItem
{
Id = id,
Type = "cylindrical_face",
Owner = component.Id,
Related = faceIndex,
Confidence = 0.78,
Source = "StructuralFaultProbe.Components.CylinderFaces",
Attributes =
{
["component_category"] = component.Category,
["radius_mm"] = FormatDouble(radius),
["diameter_mm"] = FormatDouble(radius * 2.0),
["axis"] = FormatArray(axis),
["axis_point_mm"] = FormatArray(axisPoint),
["center_mm"] = FormatArray(GetDoubleArray(face, "CenterMm")),
["bbox_mm"] = FormatArray(GetDoubleArray(face, "BBoxMm")),
["axial_min_mm"] = FormatDouble(GetDouble(face, "AxialMinMm")),
["axial_max_mm"] = FormatDouble(GetDouble(face, "AxialMaxMm")),
["loop_count"] = GetString(face, "LoopCount")
}
});
}
}
static void MergeComponentFrame(ModelEvidence evidence, string componentName, JsonElement fixation)
{
var component = evidence.Components.FirstOrDefault(c => c.Id.Equals(componentName, StringComparison.OrdinalIgnoreCase));
if (component == null)
{
component = new ComponentEvidence
{
Id = componentName,
Name = componentName,
Category = NormalizeCategory(GetString(fixation, "Category"))
};
evidence.Components.Add(component);
}
var axis = GetDoubleArray(fixation, "Axis");
var axisPoint = GetDoubleArray(fixation, "AxisPointMm");
if (axis.Length == 3)
component.Axis = axis;
if (axisPoint.Length == 3)
component.AxisPointMm = axisPoint;
component.AxialMinMm = GetDouble(fixation, "AxialMinMm");
component.AxialMaxMm = GetDouble(fixation, "AxialMaxMm");
component.InnerRadiusMm = GetNullableDouble(fixation, "InnerRadiusMm");
component.OuterRadiusMm = GetNullableDouble(fixation, "OuterRadiusMm");
}
static void AddOrMergeComponent(ModelEvidence evidence, ComponentEvidence component)
{
if (string.IsNullOrWhiteSpace(component.Id))
return;
var existing = evidence.Components.FirstOrDefault(c => c.Id.Equals(component.Id, StringComparison.OrdinalIgnoreCase));
if (existing == null)
{
evidence.Components.Add(component);
return;
}
if (string.IsNullOrWhiteSpace(existing.Name))
existing.Name = component.Name;
if (string.IsNullOrWhiteSpace(existing.PartName))
existing.PartName = component.PartName;
if (string.IsNullOrWhiteSpace(existing.Path))
existing.Path = component.Path;
bool shouldUpgradeCategory =
string.IsNullOrWhiteSpace(existing.Category) ||
existing.Category == "unknown" ||
(existing.Category == "sleeve" && component.Category == "sleeve_or_ring");
if (shouldUpgradeCategory)
existing.Category = component.Category;
if (existing.Axis.Length == 0 && component.Axis.Length == 3)
existing.Axis = component.Axis;
if (existing.AxisPointMm.Length == 0 && component.AxisPointMm.Length == 3)
existing.AxisPointMm = component.AxisPointMm;
existing.AxialMinMm = existing.AxialMinMm == 0 ? component.AxialMinMm : existing.AxialMinMm;
existing.AxialMaxMm = existing.AxialMaxMm == 0 ? component.AxialMaxMm : existing.AxialMaxMm;
existing.InnerRadiusMm ??= component.InnerRadiusMm;
existing.OuterRadiusMm ??= component.OuterRadiusMm;
}
static string ExtractComponentName(string reference)
{
var idx = reference.IndexOf(':');
return idx > 0 ? reference[..idx] : "";
}
static string GuessCategory(string name)
{
if (ContainsAny(name, "套筒", "轴套", "衬套", "sleeve", "bushing"))
return "sleeve";
if (ContainsAny(name, "端盖", "箱体", "孔", "housing", "cover", "hole"))
return "host_hole_component";
if (ContainsAny(name, "轴承", "bearing"))
return "bearing";
return "unknown";
}
static bool ContainsAny(string text, params string[] tokens)
{
return tokens.Any(t => text.Contains(t, StringComparison.OrdinalIgnoreCase));
}
static bool IsSleeveOwner(string owner)
{
return ContainsAny(owner, "套筒", "轴套", "衬套", "sleeve", "bushing");
}
static bool TryArray(JsonElement obj, string name, out JsonElement value)
{
if (obj.ValueKind == JsonValueKind.Object && obj.TryGetProperty(name, out value) && value.ValueKind == JsonValueKind.Array)
return true;
value = default;
return false;
}
static bool TryObject(JsonElement obj, string name, out JsonElement value)
{
if (obj.ValueKind == JsonValueKind.Object && obj.TryGetProperty(name, out value) && value.ValueKind == JsonValueKind.Object)
return true;
value = default;
return false;
}
static string GetString(JsonElement obj, string name)
{
if (obj.ValueKind != JsonValueKind.Object || !obj.TryGetProperty(name, out var value))
return "";
return value.ValueKind == JsonValueKind.String ? value.GetString() ?? "" : value.ToString();
}
static double GetDouble(JsonElement obj, string name)
{
if (obj.ValueKind != JsonValueKind.Object || !obj.TryGetProperty(name, out var value))
return 0;
return value.ValueKind == JsonValueKind.Number && value.TryGetDouble(out var result) ? result : 0;
}
static double? GetNullableDouble(JsonElement obj, string name)
{
if (obj.ValueKind != JsonValueKind.Object || !obj.TryGetProperty(name, out var value))
return null;
return value.ValueKind == JsonValueKind.Number && value.TryGetDouble(out var result) ? result : null;
}
static double[] GetDoubleArray(JsonElement obj, string name)
{
if (obj.ValueKind != JsonValueKind.Object || !obj.TryGetProperty(name, out var value) || value.ValueKind != JsonValueKind.Array)
return [];
return value.EnumerateArray()
.Where(v => v.ValueKind == JsonValueKind.Number && v.TryGetDouble(out _))
.Select(v => v.GetDouble())
.ToArray();
}
static IEnumerable<string> ReadStringArray(JsonElement obj, string name)
{
if (obj.ValueKind != JsonValueKind.Object || !obj.TryGetProperty(name, out var value) || value.ValueKind != JsonValueKind.Array)
yield break;
foreach (var item in value.EnumerateArray())
{
if (item.ValueKind == JsonValueKind.String)
yield return item.GetString() ?? "";
else if (item.ValueKind != JsonValueKind.Null && item.ValueKind != JsonValueKind.Undefined)
yield return item.ToString();
}
}
static string FormatDouble(double value) => value.ToString("0.####", System.Globalization.CultureInfo.InvariantCulture);
static string FormatArray(double[] values) => string.Join(",", values.Select(FormatDouble));
static string AttributeString(JsonElement obj, string key)
{
if (!TryObject(obj, "Attributes", out var attrs) && !TryObject(obj, "attributes", out attrs))
return "";
return GetString(attrs, key);
}
static bool GetBool(JsonElement obj, string name)
{
if (obj.ValueKind != JsonValueKind.Object || !obj.TryGetProperty(name, out var value))
return false;
return value.ValueKind switch
{
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.String => bool.TryParse(value.GetString(), out var b) && b,
_ => false
};
}
static string NormalizeCategory(string value)
{
var v = NormalizeToken(value);
return v switch
{
"spacer_or_ring" => "sleeve_or_ring",
"end_cap" or "housing" => "host_hole_component",
_ => v
};
}
static string NormalizeToken(string value)
{
return value.Trim().Replace(' ', '_').ToLowerInvariant();
}
}
sealed class SketchEdgeInfo
{
public string Id { get; set; } = "";
public string Owner { get; set; } = "";
public string Role { get; set; } = "";
public string Orientation { get; set; } = "";
public double Length { get; set; }
public double[] Start { get; set; } = [];
public double[] End { get; set; } = [];
public List<string> SourceEdgeIds { get; set; } = [];
}
static class GapFeatureSynthesizer
{
public static void Synthesize(JsonElement sketch, List<SketchEdgeInfo> edges, ModelEvidence evidence)
{
var usableEdges = edges
.Where(e => e.Start.Length >= 2 && e.End.Length >= 2 && EdgeLength(e) > 0.2)
.ToList();
var sleeveEdges = usableEdges.Where(e => IsSleeveOwner(e.Owner)).ToList();
var hostEdges = usableEdges.Where(e => IsHostOwner(e.Owner)).ToList();
if (sleeveEdges.Count == 0 || hostEdges.Count == 0)
return;
var polygons = BuildMaterialPolygons(sketch, usableEdges);
if (polygons.Count == 0)
return;
var contactPairs = BuildContactPairs(sketch);
var bounds = Bounds(usableEdges);
var width = bounds.MaxX - bounds.MinX;
var height = bounds.MaxY - bounds.MinY;
var span = Math.Max(width, height);
if (span <= 0)
return;
var margin = Math.Max(5.0, span * 0.05);
bounds = bounds.Expand(margin);
var resolution = Math.Clamp(span / 180.0, 0.75, 2.0);
var nx = Math.Clamp((int)Math.Ceiling((bounds.MaxX - bounds.MinX) / resolution), 20, 280);
var ny = Math.Clamp((int)Math.Ceiling((bounds.MaxY - bounds.MinY) / resolution), 20, 280);
resolution = Math.Max((bounds.MaxX - bounds.MinX) / nx, (bounds.MaxY - bounds.MinY) / ny);
var solid = new bool[nx, ny];
var outside = new bool[nx, ny];
for (int ix = 0; ix < nx; ix++)
for (int iy = 0; iy < ny; iy++)
{
var p = CellCenter(bounds, resolution, ix, iy);
solid[ix, iy] = polygons.Any(poly => PointInPolygon(p, poly.Points));
}
FloodOutside(solid, outside, nx, ny);
var candidateCells = new List<GapCell>();
const double MinimumToolGapWidthMm = 2.0;
var maxGapWidth = Math.Min(25.0, Math.Max(6.0, span * 0.16));
for (int ix = 0; ix < nx; ix++)
for (int iy = 0; iy < ny; iy++)
{
if (!outside[ix, iy] || solid[ix, iy])
continue;
var p = CellCenter(bounds, resolution, ix, iy);
var sleeveNearest = NearestEdge(p, sleeveEdges);
var hostNearest = NearestEdge(p, hostEdges);
if (IsKnownContactPair(contactPairs, sleeveNearest.Edge, hostNearest.Edge))
continue;
if (!IsBetweenMaterialBoundaries(p, sleeveNearest.Point, hostNearest.Point))
continue;
var sleeveDistance = sleeveNearest.Distance;
var hostDistance = hostNearest.Distance;
var estimatedWidth = sleeveDistance + hostDistance;
if (sleeveDistance <= maxGapWidth &&
hostDistance <= maxGapWidth &&
estimatedWidth >= MinimumToolGapWidthMm &&
estimatedWidth <= maxGapWidth)
{
candidateCells.Add(new GapCell
{
X = ix,
Y = iy,
Point = p,
SleeveDistance = sleeveDistance,
HostDistance = hostDistance,
EstimatedWidth = estimatedWidth
});
}
}
if (candidateCells.Count == 0)
return;
var clusters = BuildClusters(candidateCells, nx, ny);
var best = clusters
.Select(c => new GapCluster(c))
.Where(c => c.MinSleeveDistance <= resolution * 2.5)
.Where(c => c.MinEstimatedWidth >= MinimumToolGapWidthMm)
.Where(c => c.AxialExtent >= Math.Max(2.0, resolution * 3.0))
.Where(c => HasExteriorMouth(c, solid, nx, ny))
.OrderBy(c => c.MinEstimatedWidth)
.ThenByDescending(c => c.Cells.Count)
.FirstOrDefault();
if (best == null)
return;
var confidence = best.MinEstimatedWidth <= maxGapWidth * 0.65 ? 0.74 : 0.70;
var attributes = new Dictionary<string, string>
{
["estimated_min_width_mm"] = FormatDouble(best.MinEstimatedWidth),
["axial_extent_mm"] = FormatDouble(best.AxialExtent),
["radial_min_mm"] = FormatDouble(best.RadialMin),
["radial_max_mm"] = FormatDouble(best.RadialMax),
["centroid_axial_mm"] = FormatDouble(best.CentroidX),
["centroid_radial_mm"] = FormatDouble(best.CentroidY),
["exterior_mouth"] = "radial_clear_ray",
["cell_count"] = best.Cells.Count.ToString(System.Globalization.CultureInfo.InvariantCulture),
["grid_resolution_mm"] = FormatDouble(resolution),
["max_gap_width_mm"] = FormatDouble(maxGapWidth)
};
AddEntityOnce(evidence, "gap_region:auto_tool_gap", "region", "void_gap");
AddEvidenceOnce(evidence, "outside_connected_void_gap", "gap_region", "outside", confidence, "GapFeatureSynthesizer.SectionFloodFill", attributes);
AddEvidenceOnce(evidence, "gap_path_reaches_sleeve_push_area", "gap_region", "sleeve_push_area", confidence, "GapFeatureSynthesizer.SectionFloodFill", attributes);
AddEvidenceOnce(evidence, "tool_force_direction_valid", "gap_region", "removal_axis", 0.70, "GapFeatureSynthesizer.SectionFloodFill", attributes);
AddEvidenceOnce(evidence, "gap_width_sufficient", "gap_region", "h", confidence, "GapFeatureSynthesizer.SectionFloodFill", attributes);
evidence.SectionInstances.AddRelation("connected_to_outside", "gap_region", "outside", confidence, "GapFeatureSynthesizer.SectionFloodFill");
evidence.SectionInstances.AddRelation("reaches", "gap_region", "sleeve_push_area", confidence, "GapFeatureSynthesizer.SectionFloodFill");
evidence.SectionInstances.AddRelation("direction_has_component", "gap_region", "removal_axis", 0.70, "GapFeatureSynthesizer.SectionFloodFill");
evidence.SectionInstances.AddRelation("gap_width_sufficient", "gap_region", "h", confidence, "GapFeatureSynthesizer.SectionFloodFill");
}
static List<MaterialPolygon> BuildMaterialPolygons(JsonElement sketch, List<SketchEdgeInfo> edges)
{
var edgeMap = edges.ToDictionary(e => e.Id, StringComparer.OrdinalIgnoreCase);
var result = new List<MaterialPolygon>();
if (!TryArray(sketch, "Loops", out var loops))
return result;
foreach (var loop in loops.EnumerateArray())
{
if (!GetBool(loop, "Closed") || !TryArray(loop, "EdgeRefs", out var refs))
continue;
var loopEdges = new List<SketchEdgeInfo>();
foreach (var reference in refs.EnumerateArray())
{
if (reference.ValueKind == JsonValueKind.String &&
edgeMap.TryGetValue(reference.GetString() ?? "", out var edge))
loopEdges.Add(edge);
}
if (loopEdges.Count < 3)
continue;
var points = ChainLoopPoints(loopEdges);
if (points.Count < 3 || Math.Abs(PolygonArea(points)) < 1.0)
continue;
result.Add(new MaterialPolygon { Points = points });
}
return result;
}
static HashSet<string> BuildContactPairs(JsonElement sketch)
{
var result = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (!TryArray(sketch, "Relations", out var relations))
return result;
foreach (var relation in relations.EnumerateArray())
{
var type = NormalizeToken(GetString(relation, "Type"));
if (type != "contact")
continue;
var a = GetString(relation, "A");
var b = GetString(relation, "B");
if (!string.IsNullOrWhiteSpace(a) && !string.IsNullOrWhiteSpace(b))
result.Add(PairKey(a, b));
}
return result;
}
static List<Point2> ChainLoopPoints(List<SketchEdgeInfo> loopEdges)
{
var points = new List<Point2>();
var first = ToPoint(loopEdges[0].Start);
var current = ToPoint(loopEdges[0].End);
points.Add(first);
points.Add(current);
foreach (var edge in loopEdges.Skip(1))
{
var start = ToPoint(edge.Start);
var end = ToPoint(edge.End);
if (Distance(current, start) <= Distance(current, end))
{
current = end;
points.Add(end);
}
else
{
current = start;
points.Add(start);
}
}
if (points.Count > 1 && Distance(points[0], points[^1]) < 0.5)
points.RemoveAt(points.Count - 1);
return points;
}
static void FloodOutside(bool[,] solid, bool[,] outside, int nx, int ny)
{
var q = new Queue<(int X, int Y)>();
void Enqueue(int x, int y)
{
if (x < 0 || y < 0 || x >= nx || y >= ny || solid[x, y] || outside[x, y])
return;
outside[x, y] = true;
q.Enqueue((x, y));
}
for (int x = 0; x < nx; x++)
{
Enqueue(x, 0);
Enqueue(x, ny - 1);
}
for (int y = 0; y < ny; y++)
{
Enqueue(0, y);
Enqueue(nx - 1, y);
}
int[] dx = [1, -1, 0, 0];
int[] dy = [0, 0, 1, -1];
while (q.Count > 0)
{
var c = q.Dequeue();
for (int i = 0; i < 4; i++)
Enqueue(c.X + dx[i], c.Y + dy[i]);
}
}
static List<List<GapCell>> BuildClusters(List<GapCell> cells, int nx, int ny)
{
var byCoord = cells.ToDictionary(c => (c.X, c.Y));
var visited = new HashSet<(int X, int Y)>();
var clusters = new List<List<GapCell>>();
int[] dx = [1, -1, 0, 0];
int[] dy = [0, 0, 1, -1];
foreach (var seed in cells)
{
if (!visited.Add((seed.X, seed.Y)))
continue;
var cluster = new List<GapCell>();
var q = new Queue<GapCell>();
q.Enqueue(seed);
while (q.Count > 0)
{
var cell = q.Dequeue();
cluster.Add(cell);
for (int i = 0; i < 4; i++)
{
var key = (cell.X + dx[i], cell.Y + dy[i]);
if (key.Item1 < 0 || key.Item2 < 0 || key.Item1 >= nx || key.Item2 >= ny)
continue;
if (visited.Contains(key) || !byCoord.TryGetValue(key, out var next))
continue;
visited.Add(key);
q.Enqueue(next);
}
}
clusters.Add(cluster);
}
return clusters;
}
static bool HasExteriorMouth(GapCluster cluster, bool[,] solid, int nx, int ny)
{
return cluster.Cells.Any(c =>
HasClearRadialRay(c.X, c.Y, 1, solid, nx, ny) ||
HasClearRadialRay(c.X, c.Y, -1, solid, nx, ny));
}
static bool HasClearRadialRay(int x, int y, int dy, bool[,] solid, int nx, int ny)
{
for (var iy = y; iy >= 0 && iy < ny; iy += dy)
{
if (x < 0 || x >= nx || solid[x, iy])
return false;
}
return true;
}
static void AddEntityOnce(ModelEvidence evidence, string id, string kind, string role)
{
if (evidence.SectionInstances.Entities.Any(e => e.Id.Equals(id, StringComparison.OrdinalIgnoreCase)))
return;
evidence.SectionInstances.Entities.Add(new StructureEntity { Id = id, Kind = kind, SemanticRole = role });
}
static void AddEvidenceOnce(ModelEvidence evidence, string type, string owner, string related, double confidence, string source, Dictionary<string, string>? attributes = null)
{
if (evidence.Items.Any(i =>
i.Type.Equals(type, StringComparison.OrdinalIgnoreCase) &&
i.Owner.Equals(owner, StringComparison.OrdinalIgnoreCase) &&
i.Related.Equals(related, StringComparison.OrdinalIgnoreCase)))
return;
evidence.Items.Add(new EvidenceItem
{
Id = $"{type}:{owner}:{related}:gap",
Type = type,
Owner = owner,
Related = related,
Confidence = confidence,
Source = source,
Attributes = attributes == null
? []
: new Dictionary<string, string>(attributes, StringComparer.OrdinalIgnoreCase)
});
}
static SectionBounds Bounds(List<SketchEdgeInfo> edges)
{
var xs = edges.SelectMany(e => new[] { e.Start[0], e.End[0] }).ToList();
var ys = edges.SelectMany(e => new[] { e.Start[1], e.End[1] }).ToList();
return new SectionBounds(xs.Min(), xs.Max(), ys.Min(), ys.Max());
}
static Point2 CellCenter(SectionBounds bounds, double resolution, int ix, int iy)
{
return new Point2(bounds.MinX + (ix + 0.5) * resolution, bounds.MinY + (iy + 0.5) * resolution);
}
static bool PointInPolygon(Point2 p, List<Point2> poly)
{
var inside = false;
for (int i = 0, j = poly.Count - 1; i < poly.Count; j = i++)
{
var a = poly[i];
var b = poly[j];
if (((a.Y > p.Y) != (b.Y > p.Y)) &&
p.X < (b.X - a.X) * (p.Y - a.Y) / ((b.Y - a.Y) == 0 ? 1e-9 : (b.Y - a.Y)) + a.X)
inside = !inside;
}
return inside;
}
static NearestEdgeResult NearestEdge(Point2 p, List<SketchEdgeInfo> edges)
{
var bestEdge = edges[0];
var bestPoint = ToPoint(bestEdge.Start);
var bestDistance = double.PositiveInfinity;
foreach (var edge in edges)
{
var point = ClosestPointOnSegment(p, ToPoint(edge.Start), ToPoint(edge.End));
var distance = Distance(p, point);
if (distance < bestDistance)
{
bestEdge = edge;
bestPoint = point;
bestDistance = distance;
}
}
return new NearestEdgeResult(bestEdge, bestPoint, bestDistance);
}
static double DistancePointToSegment(Point2 p, Point2 a, Point2 b)
{
return Distance(p, ClosestPointOnSegment(p, a, b));
}
static Point2 ClosestPointOnSegment(Point2 p, Point2 a, Point2 b)
{
var dx = b.X - a.X;
var dy = b.Y - a.Y;
var len2 = dx * dx + dy * dy;
if (len2 < 1e-9)
return a;
var t = Math.Clamp(((p.X - a.X) * dx + (p.Y - a.Y) * dy) / len2, 0, 1);
return new Point2(a.X + t * dx, a.Y + t * dy);
}
static bool IsBetweenMaterialBoundaries(Point2 p, Point2 sleevePoint, Point2 hostPoint)
{
var a = Normalize(Sub(sleevePoint, p));
var b = Normalize(Sub(hostPoint, p));
return Dot(a, b) <= -0.25;
}
static bool IsKnownContactPair(HashSet<string> contactPairs, SketchEdgeInfo sleeveEdge, SketchEdgeInfo hostEdge)
{
foreach (var a in EdgeAliases(sleeveEdge))
foreach (var b in EdgeAliases(hostEdge))
{
if (contactPairs.Contains(PairKey(a, b)))
return true;
}
return false;
}
static IEnumerable<string> EdgeAliases(SketchEdgeInfo edge)
{
yield return edge.Id;
foreach (var source in edge.SourceEdgeIds)
yield return source;
}
static string PairKey(string a, string b)
{
return string.Compare(a, b, StringComparison.OrdinalIgnoreCase) <= 0
? $"{NormalizeToken(a)}|{NormalizeToken(b)}"
: $"{NormalizeToken(b)}|{NormalizeToken(a)}";
}
static double EdgeLength(SketchEdgeInfo edge) => Distance(ToPoint(edge.Start), ToPoint(edge.End));
static Point2 ToPoint(double[] value) => new(value[0], value[1]);
static double Distance(Point2 a, Point2 b)
{
var dx = a.X - b.X;
var dy = a.Y - b.Y;
return Math.Sqrt(dx * dx + dy * dy);
}
static Point2 Sub(Point2 a, Point2 b) => new(a.X - b.X, a.Y - b.Y);
static double Dot(Point2 a, Point2 b) => a.X * b.X + a.Y * b.Y;
static Point2 Normalize(Point2 p)
{
var n = Math.Sqrt(p.X * p.X + p.Y * p.Y);
return n < 1e-9 ? new Point2(0, 0) : new Point2(p.X / n, p.Y / n);
}
static double PolygonArea(List<Point2> points)
{
double area = 0;
for (int i = 0; i < points.Count; i++)
{
var a = points[i];
var b = points[(i + 1) % points.Count];
area += a.X * b.Y - b.X * a.Y;
}
return area / 2.0;
}
static string FormatDouble(double value) => value.ToString("0.####", System.Globalization.CultureInfo.InvariantCulture);
static bool IsSleeveOwner(string owner)
{
return ContainsAny(owner, "套筒", "濂楃瓛", "轴套", "衬套", "sleeve", "bushing");
}
static bool IsHostOwner(string owner)
{
return ContainsAny(owner, "端盖", "绔洊", "箱体", "孔", "housing", "cover", "hole", "host");
}
static bool ContainsAny(string text, params string[] tokens)
{
return tokens.Any(t => text.Contains(t, StringComparison.OrdinalIgnoreCase));
}
static bool TryArray(JsonElement obj, string name, out JsonElement value)
{
if (obj.ValueKind == JsonValueKind.Object && obj.TryGetProperty(name, out value) && value.ValueKind == JsonValueKind.Array)
return true;
value = default;
return false;
}
static string GetString(JsonElement obj, string name)
{
if (obj.ValueKind != JsonValueKind.Object || !obj.TryGetProperty(name, out var value))
return "";
return value.ValueKind == JsonValueKind.String ? value.GetString() ?? "" : value.ToString();
}
static string NormalizeToken(string value) => value.Trim().Replace(' ', '_').ToLowerInvariant();
static bool GetBool(JsonElement obj, string name)
{
return obj.ValueKind == JsonValueKind.Object &&
obj.TryGetProperty(name, out var value) &&
value.ValueKind is JsonValueKind.True;
}
readonly record struct Point2(double X, double Y);
readonly record struct NearestEdgeResult(SketchEdgeInfo Edge, Point2 Point, double Distance);
sealed class MaterialPolygon
{
public List<Point2> Points { get; set; } = [];
}
sealed class GapCell
{
public int X { get; set; }
public int Y { get; set; }
public Point2 Point { get; set; }
public double SleeveDistance { get; set; }
public double HostDistance { get; set; }
public double EstimatedWidth { get; set; }
}
sealed class GapCluster
{
public GapCluster(List<GapCell> cells)
{
Cells = cells;
MinSleeveDistance = cells.Min(c => c.SleeveDistance);
MinEstimatedWidth = cells.Min(c => c.EstimatedWidth);
AxialExtent = cells.Max(c => c.Point.X) - cells.Min(c => c.Point.X);
RadialMin = cells.Min(c => c.Point.Y);
RadialMax = cells.Max(c => c.Point.Y);
CentroidX = cells.Average(c => c.Point.X);
CentroidY = cells.Average(c => c.Point.Y);
}
public List<GapCell> Cells { get; }
public double MinSleeveDistance { get; }
public double MinEstimatedWidth { get; }
public double AxialExtent { get; }
public double RadialMin { get; }
public double RadialMax { get; }
public double CentroidX { get; }
public double CentroidY { get; }
}
readonly record struct SectionBounds(double MinX, double MaxX, double MinY, double MaxY)
{
public SectionBounds Expand(double amount) => new(MinX - amount, MaxX + amount, MinY - amount, MaxY + amount);
}
}
static class PortFeatureSynthesizer
{
public static void Synthesize(ModelEvidence evidence)
{
foreach (var scene in evidence.Scenes.Where(s => s.SceneType.Equals("sleeve_in_hole", StringComparison.OrdinalIgnoreCase)))
{
var sleeve = evidence.Components.FirstOrDefault(c => c.Id.Equals(scene.Target, StringComparison.OrdinalIgnoreCase));
var host = evidence.Components.FirstOrDefault(c => c.Id.Equals(scene.Host, StringComparison.OrdinalIgnoreCase));
if (sleeve == null || host == null || sleeve.Axis.Length != 3 || sleeve.AxisPointMm.Length != 3)
continue;
var sleeveAxis = Normalize(sleeve.Axis);
var sleeveAxisPoint = sleeve.AxisPointMm;
var sleeveOuterRadius = sleeve.OuterRadiusMm ?? Math.Max(host.InnerRadiusMm ?? 0, 1.0);
var hostOuterRadius = host.OuterRadiusMm ?? Math.Max(sleeveOuterRadius, 1.0);
var maxPortRadius = Math.Max(2.0, Math.Min(20.0, hostOuterRadius * 0.30));
var candidates = evidence.Items
.Where(i => i.Type.Equals("cylindrical_face", StringComparison.OrdinalIgnoreCase))
.Where(i => i.Owner.Equals(host.Id, StringComparison.OrdinalIgnoreCase))
.Select(ParseCylinder)
.Where(c => c != null)
.Select(c => c!)
.Where(c => c.RadiusMm >= 0.5 && c.RadiusMm <= maxPortRadius)
.Where(c => !LooksLikeMainCoaxialCylinder(c, sleeve, host))
.Select(c => new
{
Cylinder = c,
AxisDistance = DistanceLineToLine(c.AxisPointMm, c.Axis, sleeveAxisPoint, sleeveAxis),
DirectionComponent = Math.Abs(Dot(Normalize(c.Axis), sleeveAxis))
})
.Where(c => c.AxisDistance <= sleeveOuterRadius + c.Cylinder.RadiusMm + 2.0)
.Where(c => c.DirectionComponent >= 0.35)
.OrderBy(c => c.AxisDistance)
.ThenBy(c => c.Cylinder.RadiusMm)
.ToList();
foreach (var candidate in candidates.Take(2))
{
var id = $"port_region:{host.Id}:face_{candidate.Cylinder.FaceIndex}";
AddEntityOnce(evidence, id, "region", "ejector_or_threaded_hole");
AddEvidenceOnce(evidence, "outside_connected_cylindrical_port", host.Id, id, 0.72, "PortFeatureSynthesizer.CylinderFaces");
AddEvidenceOnce(evidence, "port_axis_intersects_sleeve_push_area", host.Id, id, 0.72, "PortFeatureSynthesizer.CylinderFaces");
AddEvidenceOnce(evidence, "port_push_direction_valid", host.Id, id, 0.72, "PortFeatureSynthesizer.CylinderFaces");
if (candidate.Cylinder.RadiusMm * 2.0 >= 1.0)
AddEvidenceOnce(evidence, "port_diameter_sufficient", host.Id, id, 0.74, "PortFeatureSynthesizer.CylinderFaces");
evidence.SectionInstances.AddRelation("connected_to_outside", "port_region", "outside", 0.72, "PortFeatureSynthesizer.CylinderFaces");
evidence.SectionInstances.AddRelation("axis_intersects", "port_axis", "sleeve_push_area", 0.72, "PortFeatureSynthesizer.CylinderFaces");
evidence.SectionInstances.AddRelation("direction_has_component", "port_axis", "removal_axis", 0.72, "PortFeatureSynthesizer.CylinderFaces");
evidence.SectionInstances.AddRelation("port_diameter_sufficient", "port_region", "t", 0.74, "PortFeatureSynthesizer.CylinderFaces");
}
}
}
static CylinderCandidate? ParseCylinder(EvidenceItem item)
{
var radius = ParseDouble(Attribute(item, "radius_mm"));
var axis = ParseArray(Attribute(item, "axis"));
var axisPoint = ParseArray(Attribute(item, "axis_point_mm"));
if (radius <= 0 || axis.Length != 3 || axisPoint.Length != 3)
return null;
return new CylinderCandidate
{
FaceIndex = item.Related,
RadiusMm = radius,
Axis = Normalize(axis),
AxisPointMm = axisPoint
};
}
static bool LooksLikeMainCoaxialCylinder(CylinderCandidate cylinder, ComponentEvidence sleeve, ComponentEvidence host)
{
if (sleeve.Axis.Length != 3 || sleeve.AxisPointMm.Length != 3)
return false;
var sleeveAxis = Normalize(sleeve.Axis);
var parallel = Math.Abs(Dot(cylinder.Axis, sleeveAxis)) >= 0.97;
if (!parallel)
return false;
var axisDistance = DistancePointToLine(cylinder.AxisPointMm, sleeve.AxisPointMm, sleeveAxis);
if (axisDistance > 2.0)
return false;
var mainRadii = new[] { sleeve.InnerRadiusMm, sleeve.OuterRadiusMm, host.InnerRadiusMm, host.OuterRadiusMm }
.Where(r => r.HasValue && r.Value > 0)
.Select(r => r!.Value);
return mainRadii.Any(r => Math.Abs(r - cylinder.RadiusMm) <= Math.Max(1.5, r * 0.05));
}
static void AddEntityOnce(ModelEvidence evidence, string id, string kind, string role)
{
if (evidence.SectionInstances.Entities.Any(e => e.Id.Equals(id, StringComparison.OrdinalIgnoreCase)))
return;
evidence.SectionInstances.Entities.Add(new StructureEntity { Id = id, Kind = kind, SemanticRole = role });
}
static void AddEvidenceOnce(ModelEvidence evidence, string type, string owner, string related, double confidence, string source)
{
if (evidence.Items.Any(i =>
i.Type.Equals(type, StringComparison.OrdinalIgnoreCase) &&
i.Owner.Equals(owner, StringComparison.OrdinalIgnoreCase) &&
i.Related.Equals(related, StringComparison.OrdinalIgnoreCase)))
return;
evidence.Items.Add(new EvidenceItem
{
Id = $"{type}:{owner}:{related}:port",
Type = type,
Owner = owner,
Related = related,
Confidence = confidence,
Source = source
});
}
static string Attribute(EvidenceItem item, string key)
{
return item.Attributes.TryGetValue(key, out var value) ? value : "";
}
static double ParseDouble(string value)
{
return double.TryParse(value, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var result)
? result
: 0;
}
static double[] ParseArray(string value)
{
if (string.IsNullOrWhiteSpace(value))
return [];
return value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(ParseDouble)
.ToArray();
}
static double Dot(double[] a, double[] b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
static double[] Sub(double[] a, double[] b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
static double[] Scale(double[] a, double s) => [a[0] * s, a[1] * s, a[2] * s];
static double Norm(double[] a) => Math.Sqrt(Dot(a, a));
static double[] Normalize(double[] a)
{
var n = Norm(a);
return n < 1e-9 ? [0, 0, 0] : [a[0] / n, a[1] / n, a[2] / n];
}
static double DistancePointToLine(double[] p, double[] linePoint, double[] lineDir)
{
var d = Normalize(lineDir);
var v = Sub(p, linePoint);
var projected = Scale(d, Dot(v, d));
return Norm(Sub(v, projected));
}
static double DistanceLineToLine(double[] p1, double[] d1Raw, double[] p2, double[] d2Raw)
{
var d1 = Normalize(d1Raw);
var d2 = Normalize(d2Raw);
var cross = Cross(d1, d2);
var crossNorm = Norm(cross);
if (crossNorm < 1e-8)
return DistancePointToLine(p1, p2, d2);
return Math.Abs(Dot(Sub(p2, p1), cross)) / crossNorm;
}
static double[] Cross(double[] a, double[] b)
{
return [
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0]
];
}
sealed class CylinderCandidate
{
public string FaceIndex { get; set; } = "";
public double RadiusMm { get; set; }
public double[] Axis { get; set; } = [];
public double[] AxisPointMm { get; set; } = [];
}
}
static class SceneRecognizer
{
public static void Recognize(ModelEvidence evidence)
{
RecognizeSleeveInHole(evidence);
RecognizeBearingMounting(evidence);
RecognizeGenericAxialLimit(evidence);
}
static void RecognizeSleeveInHole(ModelEvidence evidence)
{
var sleeves = evidence.Components
.Where(c => c.Category is "sleeve_or_ring" || ContainsAny($"{c.Name} {c.PartName}", "套筒", "轴套", "衬套", "sleeve", "bushing"))
.ToList();
var hosts = evidence.Components
.Where(c => c.Category is "host_hole_component" || ContainsAny($"{c.Name} {c.PartName}", "孔", "端盖", "箱体", "housing", "cover", "hole"))
.ToList();
foreach (var sleeve in sleeves)
foreach (var host in hosts)
{
var scene = new SceneCandidate
{
Id = $"sleeve_in_hole:{sleeve.Id}:{host.Id}",
SceneType = "sleeve_in_hole",
Target = sleeve.Id,
Host = host.Id,
Confidence = 0.45
};
scene.Evidence.Add("sleeve_component");
scene.Evidence.Add("host_hole_component");
if (evidence.Has("close_fit_contact", 0.75) || evidence.SectionInstances.HasRelation("contact"))
{
scene.Evidence.Add("close_fit_contact");
scene.Confidence = 0.78;
}
if (evidence.Has("coaxial_cylinder_pair", 0.6) || evidence.SectionInstances.HasRelation("coaxial"))
{
scene.Evidence.Add("coaxial_cylinder_pair");
scene.Confidence = Math.Max(scene.Confidence, 0.78);
}
if (evidence.Items.Any(i => i.Type == "axial_contact" && (i.Owner == sleeve.Id || i.Related == sleeve.Id)) ||
evidence.Has("axial_overlap", 0.6) ||
evidence.SectionInstances.HasRelation("axial_overlap"))
{
scene.Evidence.Add("axial_overlap");
scene.Confidence = Math.Max(scene.Confidence, 0.62);
}
AddOrMergeScene(evidence, scene);
}
}
static void RecognizeBearingMounting(ModelEvidence evidence)
{
foreach (var bearing in evidence.Components.Where(c => c.Category == "bearing" || ContainsAny(c.Name, "轴承", "bearing")))
{
var scene = new SceneCandidate
{
Id = $"bearing_mounting:{bearing.Id}",
SceneType = "bearing_mounting",
Target = bearing.Id,
Confidence = 0.75,
Evidence = ["bearing_component"]
};
foreach (var item in evidence.Items.Where(i => i.Owner == bearing.Id && i.Type.StartsWith("bearing_", StringComparison.OrdinalIgnoreCase)))
scene.Evidence.Add(item.Type);
AddOrMergeScene(evidence, scene);
}
}
static void RecognizeGenericAxialLimit(ModelEvidence evidence)
{
foreach (var component in evidence.Components)
{
bool left = evidence.Items.Any(i => i.Owner == component.Id && i.Type == "left_axial_limit");
bool right = evidence.Items.Any(i => i.Owner == component.Id && i.Type == "right_axial_limit");
if (!left && !right)
continue;
AddOrMergeScene(evidence, new SceneCandidate
{
Id = $"axial_limited_component:{component.Id}",
SceneType = "axial_limited_component",
Target = component.Id,
Confidence = left && right ? 0.82 : 0.55,
Evidence = left && right ? ["left_axial_limit", "right_axial_limit"] : left ? ["left_axial_limit"] : ["right_axial_limit"]
});
}
}
static void AddOrMergeScene(ModelEvidence evidence, SceneCandidate scene)
{
var existing = evidence.Scenes.FirstOrDefault(s =>
s.SceneType.Equals(scene.SceneType, StringComparison.OrdinalIgnoreCase) &&
s.Target.Equals(scene.Target, StringComparison.OrdinalIgnoreCase) &&
s.Host.Equals(scene.Host, StringComparison.OrdinalIgnoreCase));
if (existing == null)
{
evidence.Scenes.Add(scene);
return;
}
existing.Confidence = Math.Max(existing.Confidence, scene.Confidence);
foreach (var item in scene.Evidence)
{
if (!existing.Evidence.Contains(item, StringComparer.OrdinalIgnoreCase))
existing.Evidence.Add(item);
}
}
static bool ContainsAny(string text, params string[] tokens)
{
return tokens.Any(t => text.Contains(t, StringComparison.OrdinalIgnoreCase));
}
}
static class SectionStructureSynthesizer
{
public static void Synthesize(ModelEvidence evidence)
{
foreach (var item in evidence.Items.ToList())
{
switch (item.Type)
{
case "close_fit_contact":
evidence.SectionInstances.AddRelation("contact", "sleeve_material", "host_material", item.Confidence, item.Source);
evidence.SectionInstances.AddRelation("axial_overlap", "sleeve_material", "host_material", 0.65, item.Source);
evidence.SectionInstances.AddRelation("coaxial", "sleeve_material", "host_material", 0.55, item.Source);
AddDerivedEvidence(evidence, "axial_overlap", "section_graph", "sleeve_material|host_material", 0.65, item.Source);
AddDerivedEvidence(evidence, "coaxial_cylinder_pair", "section_graph", "sleeve_material|host_material", 0.62, item.Source);
break;
case "axial_contact":
evidence.SectionInstances.AddRelation("adjacent_to", item.Owner, item.Related, item.Confidence, item.Source);
break;
case "left_axial_limit":
case "right_axial_limit":
evidence.SectionInstances.AddRelation("limits_axial_motion", item.Related, item.Owner, item.Confidence, item.Source);
break;
}
}
}
static void AddDerivedEvidence(ModelEvidence evidence, string type, string owner, string related, double confidence, string source)
{
if (evidence.Items.Any(i => i.Type == type && i.Owner == owner && i.Related == related))
return;
var rawId = $"{type}:{owner}:{related}:derived";
evidence.Items.Add(new EvidenceItem
{
Id = rawId[..Math.Min(64, rawId.Length)],
Type = type,
Owner = owner,
Related = related,
Confidence = confidence,
Source = source
});
}
}
static class DisassemblyAccessDefinitionFilter
{
static readonly string[] DirectPushFacts =
[
"outside_accessible_push_path",
"push_path_reaches_sleeve_push_area",
"push_direction_has_removal_component"
];
static readonly string[] GapFacts =
[
"outside_connected_void_gap",
"gap_path_reaches_sleeve_push_area",
"gap_width_sufficient",
"tool_force_direction_valid"
];
public static void Apply(ModelEvidence evidence)
{
// These are not priority rules. They remove evidence only when an extractor
// produced a fact outside that fact's own definition:
// - c is an open tool gap, not a cylindrical ejector/threaded port.
// - b is a directly accessible push face, not access mediated by a gap/port.
var hasCylindricalPortAccess = HasAll(evidence, [
"outside_connected_cylindrical_port",
"port_axis_intersects_sleeve_push_area",
"port_diameter_sufficient",
"port_push_direction_valid"
]);
var hasOpenToolGapAccess = HasAll(evidence, GapFacts);
if (hasCylindricalPortAccess)
{
RemoveFacts(evidence, GapFacts, sourcePrefix: "GapFeatureSynthesizer");
RemoveGapRelations(evidence);
hasOpenToolGapAccess = false;
}
if (hasCylindricalPortAccess || hasOpenToolGapAccess)
{
RemoveFacts(evidence, DirectPushFacts, sourcePrefix: "SketchGraph.Dimensions");
RemoveDirectPushRelations(evidence);
}
}
static bool HasAll(ModelEvidence evidence, IEnumerable<string> factTypes)
{
return factTypes.All(type => evidence.Has(type, 0.7));
}
static void RemoveFacts(ModelEvidence evidence, IEnumerable<string> factTypes, string sourcePrefix)
{
var set = factTypes.ToHashSet(StringComparer.OrdinalIgnoreCase);
evidence.Items.RemoveAll(i =>
set.Contains(i.Type) &&
i.Source.StartsWith(sourcePrefix, StringComparison.OrdinalIgnoreCase));
}
static void RemoveGapRelations(ModelEvidence evidence)
{
evidence.SectionInstances.Entities.RemoveAll(e =>
e.Id.Equals("gap_region:auto_tool_gap", StringComparison.OrdinalIgnoreCase) ||
e.SemanticRole.Equals("void_gap", StringComparison.OrdinalIgnoreCase));
evidence.SectionInstances.Relations.RemoveAll(r =>
r.Source.StartsWith("GapFeatureSynthesizer", StringComparison.OrdinalIgnoreCase) ||
r.A.Equals("gap_region", StringComparison.OrdinalIgnoreCase) ||
r.B.Equals("gap_region", StringComparison.OrdinalIgnoreCase));
}
static void RemoveDirectPushRelations(ModelEvidence evidence)
{
evidence.SectionInstances.Relations.RemoveAll(r =>
r.Source.Equals("SketchGraph.Dimensions", StringComparison.OrdinalIgnoreCase) &&
(r.A.Equals("push_access_path", StringComparison.OrdinalIgnoreCase) ||
r.B.Equals("push_access_path", StringComparison.OrdinalIgnoreCase)));
}
}
static class DiagnosticPipeline
{
public static DiagnosticRunResult Run(RuleTemplatePackage package, ModelEvidence evidence, string modelInput, List<string> evidencePaths, string rulePath)
{
var result = new DiagnosticRunResult
{
Schema = "model_diagnostic_run_v1",
ModelInput = modelInput,
EvidencePaths = evidencePaths.Select(Path.GetFullPath).ToList(),
RulePackagePath = Path.GetFullPath(rulePath),
ObservedEvidence = evidence,
PipelineSummary = BuildPipelineSummary(package, evidence)
};
foreach (var rule in package.SemanticRules)
result.RuleResults.Add(RunRule(rule, package.ImageStructureTemplates, evidence));
return result;
}
static PipelineSummary BuildPipelineSummary(RuleTemplatePackage package, ModelEvidence evidence)
{
return new PipelineSummary
{
RuleCount = package.SemanticRules.Count,
ComponentCount = evidence.Components.Count,
EvidenceItemCount = evidence.Items.Count,
SceneCandidateCount = evidence.Scenes.Count,
SectionRelationCount = evidence.SectionInstances.Relations.Count,
SceneTypes = evidence.Scenes.Select(s => s.SceneType).Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(s => s).ToList(),
EvidenceTypes = evidence.Items.Select(i => i.Type).Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(s => s).ToList()
};
}
static RuleDiagnosticResult RunRule(RuleSemanticTemplate rule, List<ImageStructureTemplate2D> templates, ModelEvidence evidence)
{
var scenes = evidence.Scenes
.Where(s => s.SceneType.Equals(rule.TriggerCondition.SceneType, StringComparison.OrdinalIgnoreCase))
.OrderByDescending(s => s.Confidence)
.ToList();
if (scenes.Count == 0)
{
return new RuleDiagnosticResult
{
RuleId = rule.RuleId,
Decision = rule.DecisionLogic.IfTriggerMissing,
ProblemType = null,
Reason = "No scene candidate matched the rule trigger scene type.",
Applicability = new ApplicabilityResult { Status = "not_applicable", MissingEvidence = rule.TriggerCondition.RequiredEvidence }
};
}
var bestScene = scenes[0];
var applicability = EvaluateApplicability(rule, bestScene, evidence);
if (applicability.Status != "applicable")
{
return new RuleDiagnosticResult
{
RuleId = rule.RuleId,
Decision = rule.DecisionLogic.IfTriggerUnknown,
ProblemType = null,
Reason = "Scene candidate exists, but trigger evidence is incomplete or weak.",
Scene = bestScene,
Applicability = applicability
};
}
var matches = MatchPassEvidence(rule, templates, evidence);
var foundPass = matches.Any(m => m.Status == "found");
var decision = foundPass ? rule.DecisionLogic.IfTriggeredAndAnyPassEvidence : rule.DecisionLogic.IfTriggeredAndNoPassEvidence;
return new RuleDiagnosticResult
{
RuleId = rule.RuleId,
Decision = decision,
ProblemType = decision == "problem" ? rule.Problem.ProblemType : null,
Message = decision == "problem" ? rule.Problem.Message : "未发现该项设计问题。",
Reason = foundPass
? "Rule applies and at least one pass evidence template matched the model structure/evidence."
: "Rule applies, but no pass evidence template matched the model structure/evidence.",
Scene = bestScene,
Applicability = applicability,
PassEvidenceMatches = matches
};
}
static ApplicabilityResult EvaluateApplicability(RuleSemanticTemplate rule, SceneCandidate scene, ModelEvidence evidence)
{
var found = new List<string>();
var missing = new List<string>();
foreach (var required in rule.TriggerCondition.RequiredEvidence)
{
if (scene.Evidence.Contains(required, StringComparer.OrdinalIgnoreCase) || evidence.Has(required, 0.65) || evidence.SectionInstances.HasRelation(required))
found.Add(required);
else
missing.Add(required);
}
var enough = missing.Count == 0 ||
(found.Contains("sleeve_component") && found.Contains("host_hole_component") && found.Contains("close_fit_contact"));
return new ApplicabilityResult
{
Status = enough ? "applicable" : found.Count == 0 ? "not_applicable" : "candidate",
Confidence = enough ? Math.Max(scene.Confidence, 0.75) : scene.Confidence,
FoundEvidence = found,
MissingEvidence = missing
};
}
static List<PassEvidenceMatch> MatchPassEvidence(RuleSemanticTemplate rule, List<ImageStructureTemplate2D> templates, ModelEvidence evidence)
{
var matches = new List<PassEvidenceMatch>();
foreach (var pass in rule.PassEvidenceTypes)
{
var template = templates.FirstOrDefault(t => t.TemplateId.Equals(pass.Required2DTemplateId, StringComparison.OrdinalIgnoreCase));
var modelEvidenceFound = pass.RequiredModelEvidence.Where(req => evidence.Has(req, 0.7) || evidence.SectionInstances.HasRelation(req)).ToList();
var modelEvidenceMissing = pass.RequiredModelEvidence
.Where(req => !modelEvidenceFound.Contains(req, StringComparer.OrdinalIgnoreCase))
.ToList();
var structure = template != null
? StructureTemplateMatcher.Match(template, evidence.SectionInstances)
: new StructureMatch { Status = "missing_template" };
bool found = modelEvidenceFound.Count == pass.RequiredModelEvidence.Count && structure.Status == "found";
bool candidate = modelEvidenceFound.Count > 0 || structure.Status == "candidate";
matches.Add(new PassEvidenceMatch
{
EvidenceType = pass.EvidenceType,
SourceExample = pass.SourceExample,
Anchor = pass.Anchor,
TemplateId = pass.Required2DTemplateId,
Status = found ? "found" : candidate ? "candidate" : "missing",
MatchedModelEvidence = modelEvidenceFound,
MissingModelEvidence = modelEvidenceMissing,
MissingModelEvidenceDiagnostics = modelEvidenceMissing
.Select(BrepFactCapabilityCatalog.ExplainMissing)
.ToList(),
StructureMatch = structure
});
}
return matches;
}
}
static class StructureTemplateMatcher
{
public static StructureMatch Match(ImageStructureTemplate2D template, StructureInstance2D instance)
{
var found = new List<string>();
var missing = new List<string>();
foreach (var relation in template.RequiredRelations)
{
if (relation.Type == "missing")
continue;
var relationFound = string.IsNullOrWhiteSpace(relation.A) && string.IsNullOrWhiteSpace(relation.B)
? instance.HasRelation(relation.Type)
: instance.HasRelation(relation.Type, relation.A, relation.B);
if (relationFound)
found.Add($"{relation.Type}({relation.A},{relation.B})");
else
missing.Add($"{relation.Type}({relation.A},{relation.B})");
}
return new StructureMatch
{
TemplateId = template.TemplateId,
Status = missing.Count == 0 ? "found" : found.Count > 0 ? "candidate" : "missing",
FoundRelations = found,
MissingRelations = missing
};
}
}
static class MarkdownReport
{
public static string Write(DiagnosticRunResult run)
{
var sb = new StringBuilder();
sb.AppendLine("# Model Diagnostic Verifier");
sb.AppendLine();
sb.AppendLine($"- Model input: `{run.ModelInput}`");
sb.AppendLine($"- Evidence: `{string.Join("`, `", run.EvidencePaths)}`");
sb.AppendLine($"- Rule package: `{run.RulePackagePath}`");
sb.AppendLine();
sb.AppendLine("## Decisions");
foreach (var rule in run.RuleResults)
{
sb.AppendLine($"- `{rule.RuleId}` decision=`{rule.Decision}`, problemType=`{rule.ProblemType ?? ""}`");
sb.AppendLine($" - Reason: {rule.Reason}");
sb.AppendLine($" - Applicability: `{rule.Applicability.Status}`, found=[{string.Join(", ", rule.Applicability.FoundEvidence)}], missing=[{string.Join(", ", rule.Applicability.MissingEvidence)}]");
foreach (var match in rule.PassEvidenceMatches)
{
sb.AppendLine($" - Pass evidence `{match.EvidenceType}` example=`{match.SourceExample}` template=`{match.TemplateId}` status=`{match.Status}`");
sb.AppendLine($" - model found=[{string.Join(", ", match.MatchedModelEvidence)}], model missing=[{string.Join(", ", match.MissingModelEvidence)}]");
foreach (var diagnostic in match.MissingModelEvidenceDiagnostics)
sb.AppendLine($" - missing `{diagnostic.Fact}`: status=`{diagnostic.ExtractorStatus}`, source=`{diagnostic.SourceLayer}`, reason={diagnostic.MissingWhenAbsent}");
sb.AppendLine($" - structure found=[{string.Join(", ", match.StructureMatch.FoundRelations)}], structure missing=[{string.Join(", ", match.StructureMatch.MissingRelations)}]");
}
}
sb.AppendLine();
sb.AppendLine("## Scenes");
foreach (var scene in run.ObservedEvidence.Scenes)
sb.AppendLine($"- `{scene.SceneType}` target=`{scene.Target}`, host=`{scene.Host}`, confidence={scene.Confidence:0.00}, evidence={string.Join(", ", scene.Evidence)}");
sb.AppendLine();
sb.AppendLine("## Evidence Summary");
foreach (var item in run.ObservedEvidence.Items.Take(80))
sb.AppendLine($"- `{item.Type}` owner=`{item.Owner}`, related=`{item.Related}`, confidence={item.Confidence:0.00}, source={item.Source}");
return sb.ToString();
}
}
sealed class Args
{
readonly Dictionary<string, string> _values;
Args(Dictionary<string, string> values)
{
_values = values;
}
public static Args Parse(string[] args)
{
var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
for (int i = 0; i < args.Length; i++)
{
var key = args[i];
if (!key.StartsWith("--", StringComparison.Ordinal))
throw new ArgumentException($"Unexpected argument: {key}");
if (i + 1 >= args.Length)
throw new ArgumentException($"Missing value for {key}");
values[key] = args[++i];
}
return new Args(values);
}
public string Required(string key)
{
if (!_values.TryGetValue(key, out var value) || string.IsNullOrWhiteSpace(value))
throw new ArgumentException($"{key} is required.");
return value;
}
public string? Optional(string key)
{
return _values.TryGetValue(key, out var value) ? value : null;
}
}
sealed class DiagnosticRunResult
{
public string Schema { get; set; } = "model_diagnostic_run_v1";
public string ModelInput { get; set; } = "";
public List<string> EvidencePaths { get; set; } = [];
public string RulePackagePath { get; set; } = "";
public PipelineSummary PipelineSummary { get; set; } = new();
public ModelEvidence ObservedEvidence { get; set; } = new();
public List<RuleDiagnosticResult> RuleResults { get; set; } = [];
}
sealed class PipelineSummary
{
public int RuleCount { get; set; }
public int ComponentCount { get; set; }
public int EvidenceItemCount { get; set; }
public int SceneCandidateCount { get; set; }
public int SectionRelationCount { get; set; }
public List<string> SceneTypes { get; set; } = [];
public List<string> EvidenceTypes { get; set; } = [];
}
sealed class RuleDiagnosticResult
{
public string RuleId { get; set; } = "";
public string Decision { get; set; } = "";
public string? ProblemType { get; set; }
public string Message { get; set; } = "";
public string Reason { get; set; } = "";
public SceneCandidate? Scene { get; set; }
public ApplicabilityResult Applicability { get; set; } = new();
public List<PassEvidenceMatch> PassEvidenceMatches { get; set; } = [];
}
sealed class ApplicabilityResult
{
public string Status { get; set; } = "";
public double Confidence { get; set; }
public List<string> FoundEvidence { get; set; } = [];
public List<string> MissingEvidence { get; set; } = [];
}
sealed class PassEvidenceMatch
{
public string EvidenceType { get; set; } = "";
public string SourceExample { get; set; } = "";
public string Anchor { get; set; } = "";
public string TemplateId { get; set; } = "";
public string Status { get; set; } = "";
public List<string> MatchedModelEvidence { get; set; } = [];
public List<string> MissingModelEvidence { get; set; } = [];
public List<BrepFactCapability> MissingModelEvidenceDiagnostics { get; set; } = [];
public StructureMatch StructureMatch { get; set; } = new();
}
static class BrepFactCapabilityCatalog
{
static readonly List<BrepFactCapability> Items =
[
Fact("sleeve_component", "3d_brep+component", "implemented", "SceneRecognizer.RecognizeSleeveInHole", "component category/name plus cylindrical section/contact evidence", "No sleeve-like component candidate was recognized."),
Fact("sleeve_like_component", "3d_brep+component", "implemented", "SceneRecognizer.RecognizeSleeveInHole", "component category/name plus cylindrical section/contact evidence", "No sleeve-like component candidate was recognized."),
Fact("host_hole_component", "3d_brep+component", "implemented", "SceneRecognizer.RecognizeSleeveInHole", "host/housing component and coaxial/contact evidence", "No host or hole component candidate was recognized."),
Fact("coaxial_cylinder_pair", "2d_section_brep", "implemented", "SectionStructureSynthesizer/SketchGraph.Relations", "coaxial sleeve/host section geometry", "No coaxial sleeve-host pair was detected."),
Fact("axial_overlap", "2d_section_brep", "implemented", "SectionStructureSynthesizer/SketchGraph.Relations", "overlapping axial ranges in section", "No axial overlap between sleeve and host was detected."),
Fact("close_fit_contact", "2d_section_brep", "implemented", "SketchGraph.Relations contact", "contact or near-contact section edges", "No close-fit contact relation was detected."),
Fact("close_fit_contact_or_small_clearance", "2d_section_brep", "implemented", "SketchGraph.Relations contact/clearance", "contact or small clearance section edges", "No close-fit contact or small clearance relation was detected."),
Fact("outside_accessible_push_path", "2d_section_brep", "implemented", "ReadSketchDimension exposed_push_face_candidate", "exposed sleeve end edge and outside clearance", "No exposed sleeve push face was detected."),
Fact("push_path_reaches_sleeve_push_area", "2d_section_brep", "implemented", "ReadSketchDimension exposed_push_face_candidate", "exposed sleeve end edge", "No push path reaches a sleeve push area."),
Fact("push_direction_has_removal_component", "2d_section_brep", "implemented", "ReadSketchDimension exposed_push_face_candidate", "push face orientation against sleeve axis", "No push direction with removal-axis component was detected."),
Fact("outside_connected_void_gap", "2d_section_brep", "partial", "GapFeatureSynthesizer.SectionFloodFill", "outside-connected free-space cells between sleeve and host", "No outside-connected sleeve/host gap candidate was detected."),
Fact("gap_path_reaches_sleeve_push_area", "2d_section_brep", "partial", "GapFeatureSynthesizer.SectionFloodFill", "gap cluster adjacent to sleeve push area", "No detected gap cluster reached the sleeve push area."),
Fact("gap_width_sufficient", "2d_section_brep", "partial", "GapFeatureSynthesizer.SectionFloodFill", "estimated minimum sleeve-host free-space width", "No detected gap satisfied the minimum tool-width threshold."),
Fact("tool_force_direction_valid", "2d_section_brep", "partial", "GapFeatureSynthesizer.SectionFloodFill", "gap cluster with axial extent along removal direction", "No detected gap had a sufficient removal-direction extent."),
Fact("outside_connected_cylindrical_port", "3d_brep", "partial", "PortFeatureSynthesizer.CylinderFaces", "small host/end-cap cylindrical face treated as port candidate", "No small host/end-cap cylindrical port candidate was detected."),
Fact("port_axis_intersects_sleeve_push_area", "3d_brep", "partial", "PortFeatureSynthesizer.CylinderFaces", "port axis close enough to sleeve push area", "No port axis was found to reach the sleeve push area."),
Fact("port_diameter_sufficient", "3d_brep", "partial", "PortFeatureSynthesizer.CylinderFaces", "cylindrical port radius/diameter", "No detected port diameter satisfied the minimum threshold."),
Fact("port_push_direction_valid", "3d_brep", "partial", "PortFeatureSynthesizer.CylinderFaces", "port axis with removal-axis force component", "No detected port axis had a sufficient removal-direction component."),
Fact("thread_or_screw_feature", "feature_metadata_or_modeled_thread", "optional_only", "optional SolidWorks feature/thread metadata or modeled helical B-rep", "thread feature metadata or real thread geometry", "Pure B-rep often does not contain reliable thread semantics; keep this optional.")
];
public static BrepFactCapability ExplainMissing(string fact)
{
var normalized = NormalizeToken(fact);
var capability = Items.FirstOrDefault(i => i.Fact.Equals(normalized, StringComparison.OrdinalIgnoreCase));
return capability?.Clone() ?? new BrepFactCapability
{
Fact = normalized,
SourceLayer = "unknown",
ExtractorStatus = "unregistered",
Extractor = "",
RequiredPrimitive = "",
MissingWhenAbsent = "No B-rep fact capability is registered for this evidence name.",
AuthoringAllowed = false
};
}
static BrepFactCapability Fact(string fact, string source, string status, string extractor, string primitive, string missingWhenAbsent)
{
return new BrepFactCapability
{
Fact = fact,
SourceLayer = source,
ExtractorStatus = status,
Extractor = extractor,
RequiredPrimitive = primitive,
MissingWhenAbsent = missingWhenAbsent,
AuthoringAllowed = status is "implemented" or "partial" or "planned"
};
}
static string NormalizeToken(string value) => value.Trim().Replace(' ', '_').ToLowerInvariant();
}
sealed class BrepFactCapability
{
public string Fact { get; set; } = "";
public string SourceLayer { get; set; } = "";
public string ExtractorStatus { get; set; } = "";
public string Extractor { get; set; } = "";
public string RequiredPrimitive { get; set; } = "";
public string MissingWhenAbsent { get; set; } = "";
public bool AuthoringAllowed { get; set; }
public BrepFactCapability Clone()
{
return new BrepFactCapability
{
Fact = Fact,
SourceLayer = SourceLayer,
ExtractorStatus = ExtractorStatus,
Extractor = Extractor,
RequiredPrimitive = RequiredPrimitive,
MissingWhenAbsent = MissingWhenAbsent,
AuthoringAllowed = AuthoringAllowed
};
}
}
sealed class StructureMatch
{
public string TemplateId { get; set; } = "";
public string Status { get; set; } = "";
public List<string> FoundRelations { get; set; } = [];
public List<string> MissingRelations { get; set; } = [];
}
sealed class ModelEvidence
{
public string Schema { get; set; } = "model_evidence_v1";
public List<string> SourcePaths { get; set; } = [];
public string ModelPath { get; set; } = "";
public List<ComponentEvidence> Components { get; set; } = [];
public List<EvidenceItem> Items { get; set; } = [];
public List<SceneCandidate> Scenes { get; set; } = [];
public StructureInstance2D SectionInstances { get; set; } = new();
public bool Has(string type, double minConfidence)
{
var normalized = NormalizeToken(type);
return Items.Any(i => i.Type.Equals(normalized, StringComparison.OrdinalIgnoreCase) && i.Confidence >= minConfidence);
}
static string NormalizeToken(string value) => value.Trim().Replace(' ', '_').ToLowerInvariant();
}
sealed class ComponentEvidence
{
public string Id { get; set; } = "";
public string Name { get; set; } = "";
public string PartName { get; set; } = "";
public string Category { get; set; } = "";
public string Path { get; set; } = "";
public double[] Axis { get; set; } = [];
public double[] AxisPointMm { get; set; } = [];
public double AxialMinMm { get; set; }
public double AxialMaxMm { get; set; }
public double? InnerRadiusMm { get; set; }
public double? OuterRadiusMm { get; set; }
}
sealed class EvidenceItem
{
public string Id { get; set; } = "";
public string Type { get; set; } = "";
public string Owner { get; set; } = "";
public string Related { get; set; } = "";
public double Confidence { get; set; }
public string Source { get; set; } = "";
public Dictionary<string, string> Attributes { get; set; } = [];
}
sealed class SceneCandidate
{
public string Id { get; set; } = "";
public string SceneType { get; set; } = "";
public string Target { get; set; } = "";
public string Host { get; set; } = "";
public double Confidence { get; set; }
public List<string> Evidence { get; set; } = [];
}
sealed class StructureInstance2D
{
public string Schema { get; set; } = "structure_instance_2d_v1";
public List<StructureEntity> Entities { get; set; } = [];
public List<StructureRelation> Relations { get; set; } = [];
public void AddRelation(string type, string a, string b, double confidence, string source)
{
var normalized = NormalizeToken(type);
var existing = Relations.FirstOrDefault(r =>
r.Type.Equals(normalized, StringComparison.OrdinalIgnoreCase) &&
r.A.Equals(a, StringComparison.OrdinalIgnoreCase) &&
r.B.Equals(b, StringComparison.OrdinalIgnoreCase));
if (existing != null)
{
existing.Confidence = Math.Max(existing.Confidence, confidence);
return;
}
Relations.Add(new StructureRelation
{
Type = normalized,
A = a,
B = b,
Confidence = confidence,
Source = source
});
}
public bool HasRelation(string type)
{
var normalized = NormalizeToken(type);
return Relations.Any(r => r.Type.Equals(normalized, StringComparison.OrdinalIgnoreCase));
}
public bool HasRelation(string type, string a, string b)
{
var normalized = NormalizeToken(type);
return Relations.Any(r =>
r.Type.Equals(normalized, StringComparison.OrdinalIgnoreCase) &&
(string.IsNullOrWhiteSpace(a) || r.A.Equals(a, StringComparison.OrdinalIgnoreCase) || r.B.Equals(a, StringComparison.OrdinalIgnoreCase)) &&
(string.IsNullOrWhiteSpace(b) || r.A.Equals(b, StringComparison.OrdinalIgnoreCase) || r.B.Equals(b, StringComparison.OrdinalIgnoreCase)));
}
static string NormalizeToken(string value) => value.Trim().Replace(' ', '_').ToLowerInvariant();
}
sealed class StructureEntity
{
public string Id { get; set; } = "";
public string Kind { get; set; } = "";
public string SemanticRole { get; set; } = "";
}
sealed class StructureRelation
{
public string Type { get; set; } = "";
public string A { get; set; } = "";
public string B { get; set; } = "";
public double Confidence { get; set; }
public string Source { get; set; } = "";
}
sealed class RuleTemplatePackage
{
public string Schema { get; set; } = "";
public string PackageId { get; set; } = "";
public List<RuleSemanticTemplate> SemanticRules { get; set; } = [];
public List<ImageStructureTemplate2D> ImageStructureTemplates { get; set; } = [];
}
sealed class RuleSemanticTemplate
{
public string RuleId { get; set; } = "";
public RuleTriggerCondition TriggerCondition { get; set; } = new();
public ProblemTemplate Problem { get; set; } = new();
public List<PassEvidenceTemplate> PassEvidenceTypes { get; set; } = [];
public RuleDecisionLogic DecisionLogic { get; set; } = new();
}
sealed class RuleTriggerCondition
{
public string SceneType { get; set; } = "";
public List<string> RequiredEvidence { get; set; } = [];
}
sealed class ProblemTemplate
{
public string ProblemType { get; set; } = "";
public string Message { get; set; } = "";
}
sealed class PassEvidenceTemplate
{
public string EvidenceType { get; set; } = "";
public string SourceExample { get; set; } = "";
public string Anchor { get; set; } = "";
public string Required2DTemplateId { get; set; } = "";
public List<string> RequiredModelEvidence { get; set; } = [];
public List<string> OptionalModelEvidence { get; set; } = [];
}
sealed class RuleDecisionLogic
{
public string IfTriggerMissing { get; set; } = "not_applicable";
public string IfTriggerUnknown { get; set; } = "needs_review";
public string IfTriggeredAndAnyPassEvidence { get; set; } = "pass";
public string IfTriggeredAndNoPassEvidence { get; set; } = "problem";
}
sealed class ImageStructureTemplate2D
{
public string TemplateId { get; set; } = "";
public string Role { get; set; } = "";
public List<TemplateRelation> RequiredRelations { get; set; } = [];
}
sealed class TemplateRelation
{
public string Type { get; set; } = "";
public string A { get; set; } = "";
public string B { get; set; } = "";
}
static class JsonOptions
{
public static readonly JsonSerializerOptions Default = new()
{
PropertyNameCaseInsensitive = true,
WriteIndented = true,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals
};
}