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

1197 lines
45 KiB
C#

using System.Globalization;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Serialization;
Console.OutputEncoding = Encoding.UTF8;
try
{
var options = CliOptions.Parse(args);
if (string.IsNullOrWhiteSpace(options.BrepReportPath) || !File.Exists(options.BrepReportPath))
throw new FileNotFoundException("Missing B-rep report. Pass --brep-report <section_brep_report.json>.");
if (string.IsNullOrWhiteSpace(options.DimensionsPath) || !File.Exists(options.DimensionsPath))
throw new FileNotFoundException("Missing dimensions file. Pass --dimensions <dimension anchors json>.");
Directory.CreateDirectory(options.OutputDir);
var report = DimensionFaceMatcher.Run(options);
var outputPath = options.OutputPath ?? Path.Combine(options.OutputDir, "dimension_face_matches.json");
File.WriteAllText(outputPath, JsonSerializer.Serialize(report, JsonDefaults.Options), new UTF8Encoding(false));
Console.WriteLine($"Dimension-face matches written: {outputPath}");
Console.WriteLine($"Dimensions: {report.Dimensions.Count}; unique: {report.Dimensions.Count(d => d.IsUnique)}");
}
catch (Exception ex)
{
Console.Error.WriteLine(ex.ToString());
Environment.ExitCode = 1;
}
static class DimensionFaceMatcher
{
public static MatchReport Run(CliOptions options)
{
using var brepDoc = JsonDocument.Parse(File.ReadAllText(options.BrepReportPath));
using var dimensionDoc = JsonDocument.Parse(File.ReadAllText(options.DimensionsPath));
var faces = LoadFaces(brepDoc.RootElement);
if (faces.Count == 0)
throw new InvalidOperationException("No faces found. Expected section_brep_report.json with assemblyFaces.");
var view = ReadViewSpec(dimensionDoc.RootElement, options);
var projectedFaces = ProjectFaces(faces, view);
var modelBounds = Bounds2D.Union(projectedFaces.Select(face => face.ProjectedBBox));
projectedFaces = projectedFaces
.Select(face => face with
{
NormalizedCenter = modelBounds.Normalize(face.ProjectedCenter),
NormalizedBBox = modelBounds.Normalize(face.ProjectedBBox)
})
.ToList();
var dimensions = LoadDimensions(dimensionDoc.RootElement);
if (dimensions.Count == 0)
throw new InvalidOperationException("No dimensions found. Expected a JSON array or an object with dimensions.");
var drawingBounds = ReadDrawingBounds(dimensionDoc.RootElement)
?? Bounds2D.Union(dimensions.SelectMany(d => d.AllBoxes()));
dimensions = dimensions.Select(d => d.Normalize(drawingBounds)).ToList();
var results = dimensions
.Select(dimension => MatchDimension(dimension, projectedFaces, options.Top))
.ToList();
return new MatchReport
{
Schema = "dimension_face_match_report_v1",
BrepReportPath = Path.GetFullPath(options.BrepReportPath),
DimensionsPath = Path.GetFullPath(options.DimensionsPath),
View = view,
ModelProjectedBounds = modelBounds,
DrawingBounds = drawingBounds,
FaceCount = faces.Count,
Dimensions = results
};
}
static DimensionMatchResult MatchDimension(DrawingDimension dimension, IReadOnlyList<ProjectedFace> faces, int top)
{
var anchors = dimension.Anchors.Count > 0 ? dimension.Anchors : [dimension.FallbackAnchor()];
var anchorA = anchors[0];
var anchorB = anchors.Count > 1 ? anchors[1] : anchors[0];
var anchorCandidatesA = faces
.Select(face => ScoreAnchorFace(dimension, anchorA, face))
.OrderByDescending(score => score.Score)
.Take(30)
.ToList();
var anchorCandidatesB = faces
.Select(face => ScoreAnchorFace(dimension, anchorB, face))
.OrderByDescending(score => score.Score)
.Take(30)
.ToList();
var pairScores = new List<FacePairCandidate>();
foreach (var a in anchorCandidatesA)
{
foreach (var b in anchorCandidatesB)
{
if (a.Face.FaceRef == b.Face.FaceRef)
continue;
pairScores.Add(ScoreFacePair(dimension, a, b, swapped: false));
}
}
if (anchors.Count > 1)
{
foreach (var a in anchorCandidatesA)
{
foreach (var b in anchorCandidatesB)
{
if (a.Face.FaceRef == b.Face.FaceRef)
continue;
pairScores.Add(ScoreFacePair(dimension, b, a, swapped: true));
}
}
}
var distinctPairScores = pairScores
.GroupBy(score => UnorderedPairKey(score.FaceA.FaceRef, score.FaceB.FaceRef), StringComparer.OrdinalIgnoreCase)
.Select(group => group.OrderByDescending(score => score.Score).First())
.OrderByDescending(score => score.Score)
.ToList();
var best = distinctPairScores.Take(Math.Max(top, 1)).ToList();
var topScore = best.FirstOrDefault()?.Score ?? 0.0;
var secondScore = distinctPairScores
.Skip(1)
.FirstOrDefault()?.Score ?? 0.0;
return new DimensionMatchResult
{
DimensionId = dimension.Id,
Text = dimension.Text,
Kind = dimension.Kind,
MeasurementMm = dimension.MeasurementMm,
BBox = dimension.BBox,
AnchorCount = anchors.Count,
TopCandidates = best,
IsUnique = topScore >= 0.72 && topScore - secondScore >= 0.12,
TopScore = Math.Round(topScore, 4),
SecondScore = Math.Round(secondScore, 4),
ScoreMargin = Math.Round(topScore - secondScore, 4)
};
}
static string UnorderedPairKey(string a, string b) =>
string.Compare(a, b, StringComparison.OrdinalIgnoreCase) <= 0 ? $"{a}||{b}" : $"{b}||{a}";
static AnchorFaceScore ScoreAnchorFace(DrawingDimension dimension, DimensionAnchor anchor, ProjectedFace face)
{
var positionScore = ScoreDistance(anchor.NormalizedMidpoint, face.NormalizedCenter, 0.18);
var bboxScore = anchor.NormalizedBBox is { } anchorBox
? ScoreBoxSimilarity(anchorBox, face.NormalizedBBox)
: 0.5;
var ownerScore = ScoreOwner(dimension.OwnerComponents, face);
var typeScore = ScoreFaceKindForDimension(dimension.Kind, face.FaceKind);
var score = 0.50 * positionScore +
0.18 * bboxScore +
0.20 * ownerScore +
0.12 * typeScore;
return new AnchorFaceScore
{
Face = face,
Score = Clamp01(score),
PositionScore = positionScore,
BBoxScore = bboxScore,
OwnerScore = ownerScore,
TypeScore = typeScore
};
}
static FacePairCandidate ScoreFacePair(DrawingDimension dimension, AnchorFaceScore a, AnchorFaceScore b, bool swapped)
{
var value = ScoreValue(dimension, a.Face, b.Face);
var relation = ScoreRelation(dimension.Kind, a.Face, b.Face);
var anchorScore = (a.Score + b.Score) / 2.0;
var roleScore = ScoreRole(dimension, a.Face, b.Face);
var score = 0.45 * value.Score +
0.35 * anchorScore +
0.12 * relation +
0.08 * roleScore;
return new FacePairCandidate
{
Score = Math.Round(Clamp01(score), 4),
FaceA = FaceSummary.From(a.Face),
FaceB = FaceSummary.From(b.Face),
ExpectedMeasurementMm = value.ExpectedMeasurementMm,
MeasurementErrorMm = value.ErrorMm,
Assignment = swapped ? "anchor_b_to_face_a__anchor_a_to_face_b" : "anchor_a_to_face_a__anchor_b_to_face_b",
Components = new CandidateScoreBreakdown
{
ValueScore = Math.Round(value.Score, 4),
AnchorScore = Math.Round(anchorScore, 4),
RelationScore = Math.Round(relation, 4),
RoleScore = Math.Round(roleScore, 4),
AnchorAScore = Math.Round(a.Score, 4),
AnchorBScore = Math.Round(b.Score, 4),
AnchorAPositionScore = Math.Round(a.PositionScore, 4),
AnchorBPositionScore = Math.Round(b.PositionScore, 4)
},
Evidence = BuildEvidence(dimension, a.Face, b.Face, value)
};
}
static ValueScore ScoreValue(DrawingDimension dimension, ProjectedFace a, ProjectedFace b)
{
if (dimension.MeasurementMm is not { } measured || measured <= 0)
return new ValueScore(0.5, null, null);
var expected = ExpectedMeasurement(dimension.Kind, a, b);
if (expected is not { } value || value <= 0)
return new ValueScore(0.35, null, null);
var error = Math.Abs(measured - value);
var tolerance = Math.Max(0.5, measured * 0.025);
var score = 1.0 / (1.0 + error / tolerance);
return new ValueScore(Clamp01(score), Math.Round(value, 4), Math.Round(error, 4));
}
static double? ExpectedMeasurement(string kind, ProjectedFace a, ProjectedFace b)
{
if (kind is "diameter" or "cylindrical_fit")
{
var diameters = new[] { a.DiameterMm, b.DiameterMm }
.Where(v => v is > 0)
.Select(v => v!.Value)
.ToList();
if (diameters.Count > 0)
return diameters.Average();
}
if (kind is "plane_distance" or "linear_distance")
{
if (a.FaceKind == "plane" && b.FaceKind == "plane" && a.Normal.Length >= 3 && b.Normal.Length >= 3)
{
var dot = Math.Abs(Vec.Dot(Vec.Normalize(a.Normal), Vec.Normalize(b.Normal)));
if (dot >= 0.92)
return Math.Abs(Vec.Dot(Vec.Sub(b.CenterMm, a.CenterMm), Vec.Normalize(a.Normal)));
}
}
if (kind is "axis_distance")
{
if (a.FaceKind == "cylinder" && b.FaceKind == "cylinder" && a.Axis.Length >= 3 && b.Axis.Length >= 3 && a.AxisPointMm.Length >= 3 && b.AxisPointMm.Length >= 3)
return AxisDistanceMm(a.AxisPointMm, a.Axis, b.AxisPointMm);
}
return Distance2D(a.ProjectedCenter, b.ProjectedCenter);
}
static double ScoreRelation(string kind, ProjectedFace a, ProjectedFace b)
{
if (kind is "diameter" or "cylindrical_fit")
{
if (a.FaceKind != "cylinder" || b.FaceKind != "cylinder")
return 0.15;
var axisScore = 0.5;
if (a.Axis.Length >= 3 && b.Axis.Length >= 3)
axisScore = Math.Abs(Vec.Dot(Vec.Normalize(a.Axis), Vec.Normalize(b.Axis)));
var radiusScore = 0.5;
if (a.RadiusMm is > 0 && b.RadiusMm is > 0)
{
var tolerance = Math.Max(0.5, Math.Max(a.RadiusMm.Value, b.RadiusMm.Value) * 0.03);
radiusScore = 1.0 / (1.0 + Math.Abs(a.RadiusMm.Value - b.RadiusMm.Value) / tolerance);
}
return Clamp01(0.55 * axisScore + 0.45 * radiusScore);
}
if (kind is "plane_distance" or "linear_distance")
{
if (a.FaceKind != "plane" || b.FaceKind != "plane")
return 0.3;
if (a.Normal.Length < 3 || b.Normal.Length < 3)
return 0.55;
return Clamp01(Math.Abs(Vec.Dot(Vec.Normalize(a.Normal), Vec.Normalize(b.Normal))));
}
return a.FaceKind == b.FaceKind ? 0.65 : 0.45;
}
static double ScoreRole(DrawingDimension dimension, ProjectedFace a, ProjectedFace b)
{
var text = $"{dimension.Text} {a.FunctionalRole} {b.FunctionalRole} {a.SurfaceProcessRole} {b.SurfaceProcessRole}".ToLowerInvariant();
if (dimension.Kind is "cylindrical_fit" or "diameter")
{
var fitText = text.Contains('φ') || text.Contains("%%c") || text.Contains("h7") || text.Contains("h6") || text.Contains("fit") || text.Contains("bore") || text.Contains("bearing") || text.Contains("cylindrical");
return fitText ? 0.9 : 0.55;
}
if (dimension.Kind is "plane_distance" or "linear_distance")
{
var planeText = text.Contains("plane") || text.Contains("planar") || text.Contains("contact") || text.Contains("mating") || text.Contains("distance");
return planeText ? 0.8 : 0.55;
}
return 0.55;
}
static List<string> BuildEvidence(DrawingDimension dimension, ProjectedFace a, ProjectedFace b, ValueScore value)
{
var evidence = new List<string>();
if (dimension.MeasurementMm != null && value.ExpectedMeasurementMm != null)
evidence.Add($"measurement {dimension.MeasurementMm:0.####}mm vs expected {value.ExpectedMeasurementMm:0.####}mm");
evidence.Add($"face kinds: {a.FaceKind} <-> {b.FaceKind}");
evidence.Add($"components: {a.ComponentDisplayNameOrName} <-> {b.ComponentDisplayNameOrName}");
if (!string.IsNullOrWhiteSpace(a.FunctionalRole) || !string.IsNullOrWhiteSpace(b.FunctionalRole))
evidence.Add($"functional roles: {a.FunctionalRole} <-> {b.FunctionalRole}");
return evidence;
}
static double ScoreFaceKindForDimension(string kind, string faceKind) =>
kind switch
{
"diameter" or "cylindrical_fit" => faceKind == "cylinder" ? 1.0 : 0.15,
"plane_distance" => faceKind == "plane" ? 1.0 : 0.25,
"linear_distance" => faceKind == "plane" ? 0.75 : faceKind == "cylinder" ? 0.45 : 0.35,
"axis_distance" => faceKind == "cylinder" ? 1.0 : 0.2,
_ => 0.5
};
static double ScoreOwner(IReadOnlyList<string> owners, ProjectedFace face)
{
if (owners.Count == 0)
return 1.0;
foreach (var owner in owners)
{
if (string.IsNullOrWhiteSpace(owner))
continue;
if (ContainsNormalized(face.ComponentName, owner) ||
ContainsNormalized(face.ComponentDisplayName, owner) ||
ContainsNormalized(face.ComponentPath, owner) ||
ContainsNormalized(face.ComponentFileName, owner))
return 1.0;
}
return 0.2;
}
static bool ContainsNormalized(string left, string right) =>
NormalizeText(left).Contains(NormalizeText(right), StringComparison.OrdinalIgnoreCase) ||
NormalizeText(right).Contains(NormalizeText(left), StringComparison.OrdinalIgnoreCase);
static string NormalizeText(string value) =>
new(value.Where(ch => !char.IsWhiteSpace(ch) && ch is not '-' and not '_' and not '.').ToArray());
static double ScoreBoxSimilarity(Bounds2D a, Bounds2D b)
{
var center = ScoreDistance(a.Center, b.Center, 0.20);
var aw = Math.Max(a.Width, 1e-9);
var ah = Math.Max(a.Height, 1e-9);
var bw = Math.Max(b.Width, 1e-9);
var bh = Math.Max(b.Height, 1e-9);
var size = 1.0 / (1.0 + Math.Abs(Math.Log(aw / bw)) + Math.Abs(Math.Log(ah / bh)));
return Clamp01(0.65 * center + 0.35 * size);
}
static double ScoreDistance(Point2D a, Point2D b, double tolerance)
{
var d = Distance2D(a, b);
return Clamp01(1.0 / (1.0 + d / Math.Max(tolerance, 1e-9)));
}
static List<ProjectedFace> ProjectFaces(IReadOnlyList<ModelFace> faces, ViewSpec view)
{
return faces.Select(face =>
{
var center = ProjectPoint(face.CenterMm, view);
var points = Corners(face.BBoxMm).Select(point => ProjectPoint(point, view)).ToList();
var box = Bounds2D.Union(points);
return new ProjectedFace(
face.FaceRef,
face.ComponentName,
face.ComponentDisplayName,
face.ComponentPath,
face.ComponentFileName,
face.FaceIndex,
face.FaceKind,
face.CenterMm,
face.BBoxMm,
face.Normal,
face.AxisPointMm,
face.Axis,
face.RadiusMm,
face.DiameterMm,
face.FunctionalRole,
face.SurfaceProcessRole,
center,
box,
new Point2D(0, 0),
new Bounds2D(0, 0, 0, 0));
}).ToList();
}
static Point2D ProjectPoint(double[] p, ViewSpec view)
{
var basis = ViewBasis.FromDirection(view.Direction);
var u = Vec.Dot(p, basis.U);
var v = Vec.Dot(p, basis.V);
var radians = view.RollDeg * Math.PI / 180.0;
var cos = Math.Cos(radians);
var sin = Math.Sin(radians);
return new Point2D(u * cos - v * sin, u * sin + v * cos);
}
static List<ModelFace> LoadFaces(JsonElement root)
{
if (!TryGetProperty(root, "assemblyFaces", out var facesEl) || facesEl.ValueKind != JsonValueKind.Array)
return [];
var faces = new List<ModelFace>();
foreach (var faceEl in facesEl.EnumerateArray())
{
var componentName = ReadString(faceEl, "componentName");
var faceIndex = ReadInt(faceEl, "faceIndex") ?? 0;
var bbox = ReadDoubleArray(faceEl, "bBoxMm", "bboxMm", "bbox_mm");
var center = ReadDoubleArray(faceEl, "centerMm", "center_mm");
if (bbox.Length < 6 || center.Length < 3)
continue;
var face = new ModelFace
{
ComponentName = componentName,
ComponentDisplayName = ReadString(faceEl, "componentDisplayName"),
ComponentPath = ReadString(faceEl, "componentPath"),
ComponentFileName = ReadString(faceEl, "componentFileName"),
FaceIndex = faceIndex,
FaceRef = $"{componentName}:face#{faceIndex}",
FaceKind = ReadString(faceEl, "faceKind", "kind").ToLowerInvariant(),
AreaMm2 = ReadDouble(faceEl, "areaMm2", "area_mm2") ?? 0,
CenterMm = center,
BBoxMm = bbox,
Normal = ReadDoubleArray(faceEl, "normal"),
AxisPointMm = ReadDoubleArray(faceEl, "axisPointMm", "axis_point_mm"),
Axis = ReadDoubleArray(faceEl, "axis"),
RadiusMm = ReadDouble(faceEl, "radiusMm", "radius_mm"),
DiameterMm = ReadDouble(faceEl, "diameterMm", "diameter_mm"),
FunctionalRole = ReadString(faceEl, "functionalRole", "functional_role"),
SurfaceProcessRole = ReadString(faceEl, "surfaceProcessRole", "surface_process_role")
};
faces.Add(face);
}
return faces;
}
static List<DrawingDimension> LoadDimensions(JsonElement root)
{
var array = root.ValueKind == JsonValueKind.Array
? root
: TryGetProperty(root, "dimensions", out var dimensionsEl) && dimensionsEl.ValueKind == JsonValueKind.Array
? dimensionsEl
: default;
if (array.ValueKind != JsonValueKind.Array)
return [];
var result = new List<DrawingDimension>();
var index = 0;
foreach (var dimEl in array.EnumerateArray())
{
index++;
var bbox = ReadBounds2D(dimEl, "bbox", "box");
var measurement = ReadDouble(dimEl, "measurementMm", "measurement_mm", "measurement", "valueMm", "value_mm");
var text = FirstNonEmpty(
ReadString(dimEl, "text"),
ReadString(dimEl, "textOverride", "text_override"),
ReadString(dimEl, "rawText", "raw_text"));
var anchors = LoadAnchors(dimEl);
if (anchors.Count == 0 && bbox != null)
{
anchors.Add(new DimensionAnchor
{
Id = "bbox_center",
BBox = bbox,
Midpoint = bbox.Value.Center
});
}
var owners = ReadStringArray(dimEl, "ownerComponents", "owner_components", "components");
AddIfNotEmpty(owners, ReadString(dimEl, "ownerComponentA", "owner_component_a", "componentA", "component_a"));
AddIfNotEmpty(owners, ReadString(dimEl, "ownerComponentB", "owner_component_b", "componentB", "component_b"));
result.Add(new DrawingDimension
{
Id = FirstNonEmpty(ReadString(dimEl, "id"), ReadString(dimEl, "handle"), $"dimension_{index:0000}"),
Text = text,
Kind = InferKind(FirstNonEmpty(ReadString(dimEl, "kind"), ReadString(dimEl, "relationType", "relation_type")), text),
MeasurementMm = measurement,
BBox = bbox,
Anchors = anchors,
OwnerComponents = owners
});
}
return result;
}
static List<DimensionAnchor> LoadAnchors(JsonElement dimEl)
{
var anchors = new List<DimensionAnchor>();
if (TryGetProperty(dimEl, "anchors", out var anchorsEl) && anchorsEl.ValueKind == JsonValueKind.Array)
{
var index = 0;
foreach (var anchorEl in anchorsEl.EnumerateArray())
{
index++;
var anchor = ReadAnchor(anchorEl, $"anchor_{index}");
if (anchor != null)
anchors.Add(anchor);
}
}
foreach (var key in new[] { "anchorA", "anchorB", "anchor_a", "anchor_b" })
{
if (TryGetProperty(dimEl, key, out var anchorEl) && anchorEl.ValueKind == JsonValueKind.Object)
{
var anchor = ReadAnchor(anchorEl, key);
if (anchor != null)
anchors.Add(anchor);
}
}
return anchors;
}
static DimensionAnchor? ReadAnchor(JsonElement anchorEl, string fallbackId)
{
var bbox = ReadBounds2D(anchorEl, "bbox", "box");
var midpoint = ReadPoint2D(anchorEl, "midpoint", "center", "point");
var start = ReadPoint2D(anchorEl, "start");
var end = ReadPoint2D(anchorEl, "end");
if (midpoint == null && start != null && end != null)
midpoint = new Point2D((start.Value.X + end.Value.X) / 2.0, (start.Value.Y + end.Value.Y) / 2.0);
if (bbox == null && start != null && end != null)
bbox = Bounds2D.Union([start.Value, end.Value]);
if (midpoint == null && bbox != null)
midpoint = bbox.Value.Center;
if (midpoint == null)
return null;
return new DimensionAnchor
{
Id = FirstNonEmpty(ReadString(anchorEl, "id"), fallbackId),
BBox = bbox,
Midpoint = midpoint.Value,
Start = start,
End = end
};
}
static ViewSpec ReadViewSpec(JsonElement root, CliOptions options)
{
var direction = options.ViewDirection;
var roll = options.RollDeg;
if (TryGetProperty(root, "view", out var viewEl) && viewEl.ValueKind == JsonValueKind.Object)
{
direction = FirstNonEmpty(ReadString(viewEl, "direction", "viewDirection", "view_direction"), direction);
roll = ReadDouble(viewEl, "rollDeg", "roll_deg", "roll") ?? roll;
}
return new ViewSpec
{
Direction = NormalizeDirection(direction),
RollDeg = NormalizeRoll(roll)
};
}
static Bounds2D? ReadDrawingBounds(JsonElement root)
{
if (TryGetProperty(root, "view", out var viewEl) && viewEl.ValueKind == JsonValueKind.Object)
{
var viewBox = ReadBounds2D(viewEl, "drawingBBox", "drawing_bbox", "mainViewBBox", "main_view_bbox");
if (viewBox != null)
return viewBox;
}
return ReadBounds2D(root, "drawingBBox", "drawing_bbox", "mainViewBBox", "main_view_bbox");
}
static string InferKind(string explicitKind, string text)
{
if (!string.IsNullOrWhiteSpace(explicitKind))
return explicitKind.Trim().ToLowerInvariant();
var lower = text.ToLowerInvariant();
if (lower.Contains("h7") || lower.Contains("h6") || lower.Contains("g6") || lower.Contains("f7") || lower.Contains("fit"))
return "cylindrical_fit";
if (text.Contains('Φ') || lower.Contains("%%c") || lower.Contains("diameter"))
return "diameter";
if (lower.Contains("axis") || lower.Contains("center"))
return "axis_distance";
return "linear_distance";
}
static string NormalizeDirection(string value)
{
var v = value.Trim().ToUpperInvariant();
return v switch
{
"X" or "+X" or "PX" => "+X",
"-X" or "NX" => "-X",
"Y" or "+Y" or "PY" => "+Y",
"-Y" or "NY" => "-Y",
"Z" or "+Z" or "PZ" => "+Z",
"-Z" or "NZ" => "-Z",
_ => throw new ArgumentException($"Unsupported view direction `{value}`. Use +X/-X/+Y/-Y/+Z/-Z.")
};
}
static double NormalizeRoll(double value)
{
var roll = value % 360.0;
if (roll < 0)
roll += 360.0;
return roll;
}
static double AxisDistanceMm(double[] pointA, double[] axisA, double[] pointB)
{
var axis = Vec.Normalize(axisA);
var delta = Vec.Sub(pointB, pointA);
var along = Vec.Mul(axis, Vec.Dot(delta, axis));
return Vec.Length(Vec.Sub(delta, along));
}
static IEnumerable<double[]> Corners(double[] box)
{
var xs = new[] { box[0], box[3] };
var ys = new[] { box[1], box[4] };
var zs = new[] { box[2], box[5] };
foreach (var x in xs)
foreach (var y in ys)
foreach (var z in zs)
yield return [x, y, z];
}
static double Distance2D(Point2D a, Point2D b)
{
var dx = a.X - b.X;
var dy = a.Y - b.Y;
return Math.Sqrt(dx * dx + dy * dy);
}
static double Clamp01(double value) => Math.Min(1.0, Math.Max(0.0, value));
static bool TryGetProperty(JsonElement element, string name, out JsonElement value)
{
if (element.ValueKind == JsonValueKind.Object)
{
foreach (var property in element.EnumerateObject())
{
if (property.NameEquals(name) || string.Equals(property.Name, name, StringComparison.OrdinalIgnoreCase))
{
value = property.Value;
return true;
}
}
}
value = default;
return false;
}
static string ReadString(JsonElement element, params string[] names)
{
foreach (var name in names)
{
if (TryGetProperty(element, name, out var value))
{
if (value.ValueKind == JsonValueKind.String)
return value.GetString() ?? "";
if (value.ValueKind is JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False)
return value.ToString();
}
}
return "";
}
static double? ReadDouble(JsonElement element, params string[] names)
{
foreach (var name in names)
{
if (!TryGetProperty(element, name, out var value))
continue;
if (value.ValueKind == JsonValueKind.Number && value.TryGetDouble(out var number))
return number;
if (value.ValueKind == JsonValueKind.String && double.TryParse(value.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, out number))
return number;
}
return null;
}
static int? ReadInt(JsonElement element, params string[] names)
{
foreach (var name in names)
{
if (!TryGetProperty(element, name, out var value))
continue;
if (value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var number))
return number;
if (value.ValueKind == JsonValueKind.String && int.TryParse(value.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out number))
return number;
}
return null;
}
static double[] ReadDoubleArray(JsonElement element, params string[] names)
{
foreach (var name in names)
{
if (!TryGetProperty(element, name, out var value))
continue;
if (value.ValueKind == JsonValueKind.Array)
return value.EnumerateArray()
.Select(v => v.ValueKind == JsonValueKind.Number && v.TryGetDouble(out var d) ? d : double.NaN)
.Where(d => !double.IsNaN(d))
.ToArray();
}
return [];
}
static List<string> ReadStringArray(JsonElement element, params string[] names)
{
foreach (var name in names)
{
if (!TryGetProperty(element, name, out var value))
continue;
if (value.ValueKind == JsonValueKind.Array)
return value.EnumerateArray()
.Select(v => v.ValueKind == JsonValueKind.String ? v.GetString() ?? "" : v.ToString())
.Where(v => !string.IsNullOrWhiteSpace(v))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (value.ValueKind == JsonValueKind.String)
return value.GetString()!
.Split([';', ',', '|'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.ToList();
}
return [];
}
static Bounds2D? ReadBounds2D(JsonElement element, params string[] names)
{
foreach (var name in names)
{
if (!TryGetProperty(element, name, out var value))
continue;
if (value.ValueKind == JsonValueKind.Object)
{
var minX = ReadDouble(value, "minX", "min_x", "xMin", "xmin");
var minY = ReadDouble(value, "minY", "min_y", "yMin", "ymin");
var maxX = ReadDouble(value, "maxX", "max_x", "xMax", "xmax");
var maxY = ReadDouble(value, "maxY", "max_y", "yMax", "ymax");
if (minX != null && minY != null && maxX != null && maxY != null)
return new Bounds2D(minX.Value, minY.Value, maxX.Value, maxY.Value).Ordered();
}
if (value.ValueKind == JsonValueKind.Array)
{
var values = value.EnumerateArray()
.Where(v => v.ValueKind == JsonValueKind.Number)
.Select(v => v.GetDouble())
.ToList();
if (values.Count >= 4)
return new Bounds2D(values[0], values[1], values[2], values[3]).Ordered();
}
}
return null;
}
static Point2D? ReadPoint2D(JsonElement element, params string[] names)
{
foreach (var name in names)
{
if (!TryGetProperty(element, name, out var value))
continue;
if (value.ValueKind == JsonValueKind.Array)
{
var values = value.EnumerateArray()
.Where(v => v.ValueKind == JsonValueKind.Number)
.Select(v => v.GetDouble())
.ToList();
if (values.Count >= 2)
return new Point2D(values[0], values[1]);
}
if (value.ValueKind == JsonValueKind.Object)
{
var x = ReadDouble(value, "x", "X");
var y = ReadDouble(value, "y", "Y");
if (x != null && y != null)
return new Point2D(x.Value, y.Value);
}
}
return null;
}
static string FirstNonEmpty(params string[] values) => values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v))?.Trim() ?? "";
static void AddIfNotEmpty(List<string> values, string value)
{
if (!string.IsNullOrWhiteSpace(value) && !values.Contains(value, StringComparer.OrdinalIgnoreCase))
values.Add(value);
}
}
static class JsonDefaults
{
public static readonly JsonSerializerOptions Options = new()
{
WriteIndented = true,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
}
sealed class CliOptions
{
public string BrepReportPath { get; set; } = "";
public string DimensionsPath { get; set; } = "";
public string OutputDir { get; set; } = Path.Combine(Environment.CurrentDirectory, "runtime", "dimension-face-matcher");
public string? OutputPath { get; set; }
public string ViewDirection { get; set; } = "+X";
public double RollDeg { get; set; }
public int Top { get; set; } = 8;
public static CliOptions Parse(string[] args)
{
var options = new CliOptions();
for (var i = 0; i < args.Length; i++)
{
var arg = args[i];
string Next()
{
if (i + 1 >= args.Length)
throw new ArgumentException($"Missing value for {arg}");
return args[++i];
}
switch (arg)
{
case "--brep-report":
options.BrepReportPath = Next();
break;
case "--dimensions":
options.DimensionsPath = Next();
break;
case "--output-dir":
options.OutputDir = Next();
break;
case "--output":
options.OutputPath = Next();
break;
case "--view-direction":
options.ViewDirection = Next();
break;
case "--roll":
case "--roll-deg":
options.RollDeg = double.Parse(Next(), CultureInfo.InvariantCulture);
break;
case "--top":
options.Top = int.Parse(Next(), CultureInfo.InvariantCulture);
break;
case "-h":
case "--help":
PrintUsage();
Environment.Exit(0);
break;
default:
throw new ArgumentException($"Unknown argument: {arg}");
}
}
return options;
}
static void PrintUsage()
{
Console.WriteLine("""
Usage:
dotnet run --project tools\drawing-diagnostics\DimensionFaceMatcher\DimensionFaceMatcher.csproj -- \
--brep-report <section_brep_report.json> \
--dimensions <dimension_anchors.json> \
--view-direction +X \
--roll 270 \
--output-dir runtime\dimension-face-matcher
Dimension input schema:
{
"view": {
"direction": "+X",
"rollDeg": 270,
"drawingBBox": { "minX": 0, "minY": 0, "maxX": 300, "maxY": 200 }
},
"dimensions": [
{
"id": "dim_001",
"text": "Φ30 H7/h6",
"measurementMm": 30,
"kind": "cylindrical_fit",
"ownerComponents": ["轴", "轴承"],
"anchors": [
{ "id": "a", "bbox": { "minX": 10, "minY": 20, "maxX": 10, "maxY": 60 } },
{ "id": "b", "bbox": { "minX": 45, "minY": 20, "maxX": 45, "maxY": 60 } }
]
}
]
}
""");
}
}
sealed record MatchReport
{
public string Schema { get; init; } = "";
public string BrepReportPath { get; init; } = "";
public string DimensionsPath { get; init; } = "";
public ViewSpec View { get; init; } = new();
public Bounds2D ModelProjectedBounds { get; init; }
public Bounds2D DrawingBounds { get; init; }
public int FaceCount { get; init; }
public List<DimensionMatchResult> Dimensions { get; init; } = [];
}
sealed record DimensionMatchResult
{
public string DimensionId { get; init; } = "";
public string Text { get; init; } = "";
public string Kind { get; init; } = "";
public double? MeasurementMm { get; init; }
public Bounds2D? BBox { get; init; }
public int AnchorCount { get; init; }
public bool IsUnique { get; init; }
public double TopScore { get; init; }
public double SecondScore { get; init; }
public double ScoreMargin { get; init; }
public List<FacePairCandidate> TopCandidates { get; init; } = [];
}
sealed record FacePairCandidate
{
public double Score { get; init; }
public FaceSummary FaceA { get; init; } = new();
public FaceSummary FaceB { get; init; } = new();
public double? ExpectedMeasurementMm { get; init; }
public double? MeasurementErrorMm { get; init; }
public string Assignment { get; init; } = "";
public CandidateScoreBreakdown Components { get; init; } = new();
public List<string> Evidence { get; init; } = [];
}
sealed record FaceSummary
{
public string FaceRef { get; init; } = "";
public string ComponentName { get; init; } = "";
public string ComponentDisplayName { get; init; } = "";
public int FaceIndex { get; init; }
public string FaceKind { get; init; } = "";
public string FunctionalRole { get; init; } = "";
public string SurfaceProcessRole { get; init; } = "";
public double[] CenterMm { get; init; } = [];
public double[] BBoxMm { get; init; } = [];
public Point2D ProjectedCenter { get; init; }
public Bounds2D ProjectedBBox { get; init; }
public double? RadiusMm { get; init; }
public double? DiameterMm { get; init; }
public static FaceSummary From(ProjectedFace face) => new()
{
FaceRef = face.FaceRef,
ComponentName = face.ComponentName,
ComponentDisplayName = face.ComponentDisplayName,
FaceIndex = face.FaceIndex,
FaceKind = face.FaceKind,
FunctionalRole = face.FunctionalRole,
SurfaceProcessRole = face.SurfaceProcessRole,
CenterMm = face.CenterMm,
BBoxMm = face.BBoxMm,
ProjectedCenter = face.ProjectedCenter,
ProjectedBBox = face.ProjectedBBox,
RadiusMm = face.RadiusMm,
DiameterMm = face.DiameterMm
};
}
sealed record CandidateScoreBreakdown
{
public double ValueScore { get; init; }
public double AnchorScore { get; init; }
public double RelationScore { get; init; }
public double RoleScore { get; init; }
public double AnchorAScore { get; init; }
public double AnchorBScore { get; init; }
public double AnchorAPositionScore { get; init; }
public double AnchorBPositionScore { get; init; }
}
sealed record DrawingDimension
{
public string Id { get; init; } = "";
public string Text { get; init; } = "";
public string Kind { get; init; } = "linear_distance";
public double? MeasurementMm { get; init; }
public Bounds2D? BBox { get; init; }
public List<DimensionAnchor> Anchors { get; init; } = [];
public List<string> OwnerComponents { get; init; } = [];
public DrawingDimension Normalize(Bounds2D bounds) => this with
{
BBox = BBox == null ? null : bounds.Normalize(BBox.Value),
Anchors = Anchors.Select(a => a.Normalize(bounds)).ToList()
};
public DimensionAnchor FallbackAnchor() => new()
{
Id = "dimension_bbox_center",
BBox = BBox,
Midpoint = BBox?.Center ?? new Point2D(0, 0),
NormalizedMidpoint = BBox?.Center ?? new Point2D(0, 0),
NormalizedBBox = BBox
};
public IEnumerable<Bounds2D> AllBoxes()
{
if (BBox != null)
yield return BBox.Value;
foreach (var anchor in Anchors)
{
if (anchor.BBox != null)
yield return anchor.BBox.Value;
}
}
}
sealed record DimensionAnchor
{
public string Id { get; init; } = "";
public Bounds2D? BBox { get; init; }
public Point2D Midpoint { get; init; }
public Point2D? Start { get; init; }
public Point2D? End { get; init; }
public Point2D NormalizedMidpoint { get; init; }
public Bounds2D? NormalizedBBox { get; init; }
public DimensionAnchor Normalize(Bounds2D bounds) => this with
{
NormalizedMidpoint = bounds.Normalize(Midpoint),
NormalizedBBox = BBox == null ? null : bounds.Normalize(BBox.Value)
};
}
sealed record ModelFace
{
public string FaceRef { get; init; } = "";
public string ComponentName { get; init; } = "";
public string ComponentDisplayName { get; init; } = "";
public string ComponentPath { get; init; } = "";
public string ComponentFileName { get; init; } = "";
public int FaceIndex { get; init; }
public string FaceKind { get; init; } = "";
public double AreaMm2 { get; init; }
public double[] CenterMm { get; init; } = [];
public double[] BBoxMm { get; init; } = [];
public double[] Normal { get; init; } = [];
public double[] AxisPointMm { get; init; } = [];
public double[] Axis { get; init; } = [];
public double? RadiusMm { get; init; }
public double? DiameterMm { get; init; }
public string FunctionalRole { get; init; } = "";
public string SurfaceProcessRole { get; init; } = "";
}
sealed record ProjectedFace(
string FaceRef,
string ComponentName,
string ComponentDisplayName,
string ComponentPath,
string ComponentFileName,
int FaceIndex,
string FaceKind,
double[] CenterMm,
double[] BBoxMm,
double[] Normal,
double[] AxisPointMm,
double[] Axis,
double? RadiusMm,
double? DiameterMm,
string FunctionalRole,
string SurfaceProcessRole,
Point2D ProjectedCenter,
Bounds2D ProjectedBBox,
Point2D NormalizedCenter,
Bounds2D NormalizedBBox)
{
public string ComponentDisplayNameOrName => string.IsNullOrWhiteSpace(ComponentDisplayName) ? ComponentName : ComponentDisplayName;
}
sealed record AnchorFaceScore
{
public ProjectedFace Face { get; init; } = null!;
public double Score { get; init; }
public double PositionScore { get; init; }
public double BBoxScore { get; init; }
public double OwnerScore { get; init; }
public double TypeScore { get; init; }
}
readonly record struct ValueScore(double Score, double? ExpectedMeasurementMm, double? ErrorMm);
sealed record ViewSpec
{
public string Direction { get; init; } = "+X";
public double RollDeg { get; init; }
}
readonly record struct ViewBasis(double[] U, double[] V)
{
public static ViewBasis FromDirection(string direction) => direction switch
{
"+X" => new([0, 1, 0], [0, 0, 1]),
"-X" => new([0, -1, 0], [0, 0, 1]),
"+Y" => new([-1, 0, 0], [0, 0, 1]),
"-Y" => new([1, 0, 0], [0, 0, 1]),
"+Z" => new([1, 0, 0], [0, 1, 0]),
"-Z" => new([-1, 0, 0], [0, 1, 0]),
_ => throw new ArgumentException($"Unsupported direction `{direction}`")
};
}
readonly record struct Point2D(double X, double Y);
readonly record struct Bounds2D(double MinX, double MinY, double MaxX, double MaxY)
{
public double Width => Math.Max(MaxX - MinX, 1e-9);
public double Height => Math.Max(MaxY - MinY, 1e-9);
public Point2D Center => new((MinX + MaxX) / 2.0, (MinY + MaxY) / 2.0);
public Bounds2D Ordered() => new(Math.Min(MinX, MaxX), Math.Min(MinY, MaxY), Math.Max(MinX, MaxX), Math.Max(MinY, MaxY));
public Point2D Normalize(Point2D point) => new((point.X - Center.X) / Width, (point.Y - Center.Y) / Height);
public Bounds2D Normalize(Bounds2D box)
{
var a = Normalize(new Point2D(box.MinX, box.MinY));
var b = Normalize(new Point2D(box.MaxX, box.MaxY));
return new Bounds2D(a.X, a.Y, b.X, b.Y).Ordered();
}
public static Bounds2D Union(IEnumerable<Point2D> points)
{
var list = points.ToList();
if (list.Count == 0)
return new Bounds2D(-0.5, -0.5, 0.5, 0.5);
return new Bounds2D(
list.Min(p => p.X),
list.Min(p => p.Y),
list.Max(p => p.X),
list.Max(p => p.Y));
}
public static Bounds2D Union(IEnumerable<Bounds2D> boxes)
{
var list = boxes.ToList();
if (list.Count == 0)
return new Bounds2D(-0.5, -0.5, 0.5, 0.5);
return new Bounds2D(
list.Min(b => b.MinX),
list.Min(b => b.MinY),
list.Max(b => b.MaxX),
list.Max(b => b.MaxY));
}
}
static class Vec
{
public static double Dot(double[] a, double[] b) => a.Zip(b).Sum(pair => pair.First * pair.Second);
public static double[] Sub(double[] a, double[] b) => a.Zip(b).Select(pair => pair.First - pair.Second).ToArray();
public static double[] Mul(double[] a, double scale) => a.Select(v => v * scale).ToArray();
public static double Length(double[] a) => Math.Sqrt(a.Sum(v => v * v));
public static double[] Normalize(double[] a)
{
var length = Length(a);
return length <= 1e-9 ? a.ToArray() : a.Select(v => v / length).ToArray();
}
}