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 ."); if (string.IsNullOrWhiteSpace(options.DimensionsPath) || !File.Exists(options.DimensionsPath)) throw new FileNotFoundException("Missing dimensions file. Pass --dimensions ."); 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 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(); 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 BuildEvidence(DrawingDimension dimension, ProjectedFace a, ProjectedFace b, ValueScore value) { var evidence = new List(); 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 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 ProjectFaces(IReadOnlyList 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 LoadFaces(JsonElement root) { if (!TryGetProperty(root, "assemblyFaces", out var facesEl) || facesEl.ValueKind != JsonValueKind.Array) return []; var faces = new List(); 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 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(); 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 LoadAnchors(JsonElement dimEl) { var anchors = new List(); 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 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 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 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 \ --dimensions \ --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 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 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 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 Anchors { get; init; } = []; public List 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 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 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 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(); } }