static class PartModelProcessing { public const string DirectPartBrepSource = "part_brep_direct"; public static List BuildComponentHighlightImageRequests(SectionBrepReport report) => []; public static List BuildFeatureHighlightImageRequests(SectionBrepReport report) { var result = new List(); var component = report.AssemblyComponents .FirstOrDefault(c => c.ReviewScope.Equals("part_design_required", StringComparison.OrdinalIgnoreCase)) ?? report.AssemblyComponents.FirstOrDefault(); if (component == null) return result; var facesByRef = report.AssemblyFaces .GroupBy(FaceRef, StringComparer.OrdinalIgnoreCase) .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase); foreach (var feature in report.FeatureGraph.Features .Where(f => f.FaceRefs.Count > 0) .Where(f => SameComponent(f, component)) .OrderBy(f => FeatureHighlightPriority(f.Type)) .ThenBy(f => f.Id, StringComparer.OrdinalIgnoreCase)) { var targetFaces = feature.FaceRefs .Select(faceRef => facesByRef.TryGetValue(faceRef, out var face) ? face : null) .OfType() .ToList(); if (targetFaces.Count == 0) continue; var viewChoice = ChooseBestObliqueFeatureView(feature, targetFaces, report.AssemblyFaces); result.Add(new FeatureHighlightImageRequest { PlanId = $"feature_highlight_{SanitizeId(feature.Id)}", ComponentId = component.Id, InstanceName = component.InstanceName, DisplayName = component.DisplayName, ComponentPath = component.Path, FeatureId = feature.Id, FeatureType = feature.Type, FaceRefs = feature.FaceRefs.Distinct(StringComparer.OrdinalIgnoreCase).ToList(), Views = [viewChoice.ViewName], PreferredDirectionMm = Vec.Round(viewChoice.Direction), BlockingFaceRefs = viewChoice.BlockingFaceRefs, SelectionBasis = "feature_graph_face_refs_grouped_before_image_export" }); } return result; } public static List BuildComponentContextImageRequests(SectionBrepReport report) => []; public static AutoSectionImagePlan BuildAxisSectionPlan(SectionBrepReport report) { var plan = new AutoSectionImagePlan { MaxRequests = 1 }; var axisFace = report.AssemblyFaces .Where(face => face.FaceKind.Equals("cylinder", StringComparison.OrdinalIgnoreCase)) .Where(face => face.Axis.Length >= 3 && face.AxisPointMm.Length >= 3) .OrderByDescending(face => face.AreaMm2) .ThenByDescending(face => face.RadiusMm) .FirstOrDefault(); if (axisFace == null) return plan; var containedAxis = Vec.Normalize(axisFace.Axis); var coaxialFaces = report.AssemblyFaces .Where(face => face.FaceKind.Equals("cylinder", StringComparison.OrdinalIgnoreCase)) .Where(face => face.Axis.Length >= 3 && face.AxisPointMm.Length >= 3) .Where(face => Math.Abs(Vec.Dot(Vec.Normalize(face.Axis), containedAxis)) >= 0.95) .ToList(); if (!HasInternalRotationalStructure(coaxialFaces)) return plan; var target = string.IsNullOrWhiteSpace(axisFace.ComponentDisplayName) ? axisFace.ComponentName : axisFace.ComponentDisplayName; var planeCandidates = ChooseSectionPlaneKeys(containedAxis); for (var i = 0; i < Math.Min(plan.MaxRequests, planeCandidates.Count); i++) { var candidate = planeCandidates[i]; plan.Requests.Add(new AutoSectionImageRequest { Request = $"part_axis_center_section_{i + 1}", Rule = "For SLDPRT rotational parts with internal/coaxial structure, create a section plane through the main cylindrical axis.", Target = target, Reason = "Part input has no assembly components to hide or highlight; section evidence is generated directly from the opened part body.", PlaneKey = candidate.PlaneKey, OffsetMm = 0, OriginMm = Vec.Round(axisFace.AxisPointMm), PreferredNormalMm = Vec.Round(candidate.Normal), PreferredContainedAxisMm = Vec.Round(containedAxis), SourceRefs = [$"{axisFace.ComponentName}:face#{axisFace.FaceIndex}"] }); } return plan; } public static List BuildSectionViewRequests(SectionBrepReport report) { return report.SectionImagePlan.Requests .Where(request => !string.IsNullOrWhiteSpace(request.PlaneKey)) .Take(2) .Select(request => $"{request.PlaneKey}:{request.Request}|target={SanitizeMetadata(request.Target)};reason={SanitizeMetadata(request.Reason)}") .ToList(); } static bool HasInternalRotationalStructure(IReadOnlyList coaxialFaces) { if (coaxialFaces.Count < 2) return false; var radiusLayers = coaxialFaces .Where(face => face.RadiusMm > 0) .Select(face => Math.Round(face.RadiusMm, 1)) .Distinct() .Count(); if (radiusLayers >= 2) return true; return coaxialFaces.Any(face => face.FunctionalRole.Contains("hole", StringComparison.OrdinalIgnoreCase) || face.FunctionalRole.Contains("bore", StringComparison.OrdinalIgnoreCase) || face.SurfaceProcessRole.Contains("hole", StringComparison.OrdinalIgnoreCase) || face.SurfaceProcessRole.Contains("bore", StringComparison.OrdinalIgnoreCase)); } static List<(string PlaneKey, double[] Normal)> ChooseSectionPlaneKeys(double[] containedAxis) { var candidates = new List<(string PlaneKey, double[] Normal)> { ("front", [0.0, 0.0, 1.0]), ("top", [0.0, 1.0, 0.0]), ("right", [1.0, 0.0, 0.0]) }; return candidates .OrderBy(candidate => Math.Abs(Vec.Dot(containedAxis, candidate.Normal))) .ThenBy(candidate => candidate.PlaneKey, StringComparer.OrdinalIgnoreCase) .Take(2) .ToList(); } static string SanitizeMetadata(string value) => (value ?? "").Replace(';', ',').Replace('|', '/').Replace('\r', ' ').Replace('\n', ' ').Trim(); static bool SameComponent(MechanicalFeature feature, AssemblyComponentSummary component) { if (!string.IsNullOrWhiteSpace(feature.ComponentPath) && !string.IsNullOrWhiteSpace(component.Path) && feature.ComponentPath.Equals(component.Path, StringComparison.OrdinalIgnoreCase)) return true; return feature.ComponentName.Equals(component.InstanceName, StringComparison.OrdinalIgnoreCase); } static int FeatureHighlightPriority(string featureType) { if (featureType.Contains("unknown", StringComparison.OrdinalIgnoreCase) || featureType.Contains("complex", StringComparison.OrdinalIgnoreCase)) return 0; if (featureType.Contains("hole", StringComparison.OrdinalIgnoreCase)) return 1; if (featureType.Contains("cylindrical", StringComparison.OrdinalIgnoreCase)) return 2; if (featureType.Contains("planar", StringComparison.OrdinalIgnoreCase)) return 3; return 4; } static (string ViewName, double[] Direction, List BlockingFaceRefs) ChooseBestObliqueFeatureView( MechanicalFeature feature, IReadOnlyList targetFaces, IReadOnlyList allFaces) { var targetBox = UnionBoxes(targetFaces.Select(f => f.BBoxMm)); if (targetBox.Length < 6) return ("isometric", [1.0, 1.0, 1.0], []); var targetRefs = feature.FaceRefs.ToHashSet(StringComparer.OrdinalIgnoreCase); var others = allFaces .Where(face => !targetRefs.Contains(FaceRef(face))) .Where(face => face.BBoxMm.Length >= 6) .ToList(); var isPlanarTarget = IsPlanarFeatureTarget(feature, targetFaces); var preferredViewSide = DeterminePreferredViewSide(feature, targetFaces, allFaces, targetBox); var candidates = BuildObliqueCandidateDirections(false); candidates = EnforcePreferredViewSide(candidates, preferredViewSide, isPlanarTarget); var projectedTargetAreas = candidates .Select(direction => ProjectBox(targetBox, direction).Area) .ToList(); var maxTargetArea = Math.Max(1.0, projectedTargetAreas.Max()); var bestScore = double.MaxValue; var bestDirection = candidates[0]; var bestBlocking = new List(); for (var i = 0; i < candidates.Count; i++) { var direction = candidates[i]; var targetProjection = ProjectBox(targetBox, direction); var blockers = new List<(string Ref, double Penalty)>(); var score = (maxTargetArea - targetProjection.Area) / maxTargetArea * (isPlanarTarget ? 0.08 : 0.35); var absDirection = direction.Select(Math.Abs).ToArray(); var maxComponent = Math.Max(absDirection[0], Math.Max(absDirection[1], absDirection[2])); var minComponent = Math.Min(absDirection[0], Math.Min(absDirection[1], absDirection[2])); score += (maxComponent - minComponent) / Math.Max(maxComponent, 0.001) * (isPlanarTarget ? 0.04 : 0.25); score += PreferredViewSidePenalty(direction, preferredViewSide, isPlanarTarget); score += StableNonPlanarAzimuthPenalty(feature, targetFaces, direction, preferredViewSide, isPlanarTarget); foreach (var other in others) { var otherProjection = ProjectBox(other.BBoxMm, direction); var overlap = OverlapArea(targetProjection, otherProjection); if (overlap <= 0.001) continue; var inFrontGap = otherProjection.MaxDepth - targetProjection.MinDepth; if (inFrontGap <= 0) continue; var penalty = overlap / Math.Max(targetProjection.Area, 1.0) * (1.0 + Math.Min(inFrontGap, 100.0) / 200.0); score += penalty; blockers.Add((FaceRef(other), penalty)); } if (score < bestScore) { bestScore = score; bestDirection = direction; bestBlocking = blockers .OrderByDescending(item => item.Penalty) .Take(8) .Select(item => item.Ref) .ToList(); } } return (ObliqueDirectionName(bestDirection), bestDirection, bestBlocking); } static bool IsPlanarFeatureTarget(MechanicalFeature feature, IReadOnlyList targetFaces) { if (feature.Type.Contains("planar", StringComparison.OrdinalIgnoreCase)) return true; return targetFaces.Count > 0 && targetFaces.All(face => face.FaceKind.Equals("plane", StringComparison.OrdinalIgnoreCase)); } static double[] DeterminePreferredViewSide( MechanicalFeature feature, IReadOnlyList targetFaces, IReadOnlyList allFaces, double[] targetBox) { var planeNormal = WeightedAverageNormal(targetFaces .Where(face => face.FaceKind.Equals("plane", StringComparison.OrdinalIgnoreCase)) .Where(face => face.Normal.Length >= 3)); if (planeNormal.Length >= 3) return planeNormal; var componentFaces = allFaces .Where(face => face.ComponentName.Equals(feature.ComponentName, StringComparison.OrdinalIgnoreCase)) .Where(face => face.BBoxMm.Length >= 6) .ToList(); if (componentFaces.Count == 0) componentFaces = allFaces.Where(face => face.BBoxMm.Length >= 6).ToList(); var modelBox = UnionBoxes(componentFaces.Select(face => face.BBoxMm)); var targetCenter = BoxCenter(targetBox); var modelCenter = BoxCenter(modelBox); if (targetCenter.Length < 3 || modelCenter.Length < 3 || modelBox.Length < 6) return []; var verticalOffset = targetCenter[1] - modelCenter[1]; if (Math.Abs(verticalOffset) >= 0.001) return verticalOffset > 0 ? [0.0, 1.0, 0.0] : [0.0, -1.0, 0.0]; return []; } static double[] WeightedAverageNormal(IEnumerable faces) { var sum = new[] { 0.0, 0.0, 0.0 }; var totalWeight = 0.0; foreach (var face in faces) { var normal = Vec.Normalize(face.Normal); if (normal.Length < 3) continue; var weight = Math.Max(face.AreaMm2, 1.0); sum = Vec.Add(sum, Vec.Mul(normal, weight)); totalWeight += weight; } if (totalWeight <= 0.0 || Vec.Norm(sum) < 1e-6) return []; return Vec.Normalize(sum); } static List EnforcePreferredViewSide(List candidates, double[] preferredViewSide, bool preferSteeperPlanarOblique) { if (preferredViewSide.Length < 3) return candidates; var preferred = Vec.Normalize(preferredViewSide); var minimumAlignment = preferSteeperPlanarOblique ? 0.30 : 0.7; var maximumAlignment = preferSteeperPlanarOblique ? 0.78 : 1.0; var filtered = candidates .Where(direction => { var alignment = Vec.Dot(Vec.Normalize(direction), preferred); return alignment >= minimumAlignment && alignment <= maximumAlignment; }) .ToList(); if (filtered.Count == 0 && preferSteeperPlanarOblique) { filtered = candidates .Where(direction => Vec.Dot(Vec.Normalize(direction), preferred) >= minimumAlignment) .ToList(); } return filtered.Count > 0 ? filtered : candidates; } static double PreferredViewSidePenalty(double[] direction, double[] preferredViewSide, bool preferSteeperPlanarOblique) { if (preferredViewSide.Length < 3) return 0.0; var alignment = Vec.Dot(Vec.Normalize(direction), Vec.Normalize(preferredViewSide)); if (preferSteeperPlanarOblique) { const double targetPlanarAlignment = 0.45; return Math.Abs(alignment - targetPlanarAlignment) * 0.55; } return (1.0 - alignment) * 0.55; } static double StableNonPlanarAzimuthPenalty( MechanicalFeature feature, IReadOnlyList targetFaces, double[] direction, double[] preferredViewSide, bool isPlanarTarget) { if (isPlanarTarget || preferredViewSide.Length < 3) return 0.0; var hasCurvedOrUnknownTarget = targetFaces.Any(face => face.FaceKind.Equals("cylinder", StringComparison.OrdinalIgnoreCase) || face.FaceKind.Equals("cone", StringComparison.OrdinalIgnoreCase) || face.FaceKind.Equals("other", StringComparison.OrdinalIgnoreCase)); if (!hasCurvedOrUnknownTarget) return 0.0; var preferred = Vec.Normalize(preferredViewSide); if (preferred.Length < 3 || Math.Abs(preferred[1]) < 0.5) return 0.0; // When curved/unknown faces only tell us "upper/lower", X/Z is otherwise decided // by small bbox-score differences. Keep a stable oblique quadrant for direct part // images so adjacent main-axis cylindrical features are viewed consistently. var stableOblique = Vec.Normalize([1.0, preferred[1] >= 0 ? 2.0 : -2.0, -1.0]); var alignment = Vec.Dot(Vec.Normalize(direction), stableOblique); var weight = feature.Type.Contains("main_axis_cylindrical", StringComparison.OrdinalIgnoreCase) ? 0.35 : 0.18; return (1.0 - alignment) * weight; } static double[] ProjectOntoPlane(double[] vector, double[] planeNormal) { var normal = Vec.Normalize(planeNormal); var v = Vec.Normalize(vector); if (normal.Length < 3 || v.Length < 3) return []; return Vec.Normalize(Vec.Sub(v, Vec.Mul(normal, Vec.Dot(v, normal)))); } static string ObliqueDirectionName(double[] direction) { static string Sign(double value, string axis) => value >= 0 ? $"{axis}p" : $"{axis}n"; var ax = Math.Abs(direction.ElementAtOrDefault(0)); var ay = Math.Abs(direction.ElementAtOrDefault(1)); var az = Math.Abs(direction.ElementAtOrDefault(2)); var min = Math.Max(0.001, Math.Min(ax, Math.Min(ay, az))); var wx = Math.Max(1, (int)Math.Round(ax / min)); var wy = Math.Max(1, (int)Math.Round(ay / min)); var wz = Math.Max(1, (int)Math.Round(az / min)); var signName = $"oblique_{Sign(direction.ElementAtOrDefault(0), "x")}_{Sign(direction.ElementAtOrDefault(1), "y")}_{Sign(direction.ElementAtOrDefault(2), "z")}"; return wx == 1 && wy == 1 && wz == 1 ? signName : $"{signName}_x{wx}_y{wy}_z{wz}"; } static List BuildObliqueCandidateDirections(bool includeSteepPlanarObliques) { var signs = new[] { -1.0, 1.0 }; var result = new List(); foreach (var sx in signs) foreach (var sy in signs) foreach (var sz in signs) { result.Add(Vec.Normalize([sx, sy, sz])); } return result; } static double[] UnionBoxes(IEnumerable boxes) { var valid = boxes.Where(box => box.Length >= 6).ToList(); if (valid.Count == 0) return []; return [ valid.Min(box => box[0]), valid.Min(box => box[1]), valid.Min(box => box[2]), valid.Max(box => box[3]), valid.Max(box => box[4]), valid.Max(box => box[5]) ]; } static double[] BoxCenter(double[] box) => box.Length >= 6 ? [(box[0] + box[3]) / 2.0, (box[1] + box[4]) / 2.0, (box[2] + box[5]) / 2.0] : []; static ProjectedBox ProjectBox(double[] box, double[] viewDirection) { var d = Vec.Normalize(viewDirection); var up = Math.Abs(Vec.Dot(d, [0.0, 1.0, 0.0])) > 0.92 ? new[] { 0.0, 0.0, 1.0 } : new[] { 0.0, 1.0, 0.0 }; var u = Vec.Normalize(Vec.Cross(up, d)); var v = Vec.Normalize(Vec.Cross(d, u)); var corners = BoxCorners(box); var uValues = corners.Select(p => Vec.Dot(p, u)).ToList(); var vValues = corners.Select(p => Vec.Dot(p, v)).ToList(); var dValues = corners.Select(p => Vec.Dot(p, d)).ToList(); return new ProjectedBox( uValues.Min(), uValues.Max(), vValues.Min(), vValues.Max(), dValues.Min(), dValues.Max()); } static List BoxCorners(double[] box) => [ [box[0], box[1], box[2]], [box[0], box[1], box[5]], [box[0], box[4], box[2]], [box[0], box[4], box[5]], [box[3], box[1], box[2]], [box[3], box[1], box[5]], [box[3], box[4], box[2]], [box[3], box[4], box[5]] ]; static double OverlapArea(ProjectedBox a, ProjectedBox b) { var width = Math.Max(0.0, Math.Min(a.MaxU, b.MaxU) - Math.Max(a.MinU, b.MinU)); var height = Math.Max(0.0, Math.Min(a.MaxV, b.MaxV) - Math.Max(a.MinV, b.MinV)); return width * height; } static string FaceRef(AssemblyFaceEvidence face) => $"{face.ComponentName}:face#{face.FaceIndex}"; static string SanitizeId(string value) { var chars = value.Select(ch => char.IsLetterOrDigit(ch) ? ch : '_').ToArray(); return new string(chars).Trim('_'); } } sealed record ProjectedBox(double MinU, double MaxU, double MinV, double MaxV, double MinDepth, double MaxDepth) { public double Area => Math.Max(0.0, MaxU - MinU) * Math.Max(0.0, MaxV - MinV); }