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

506 lines
20 KiB
C#

static class PartModelProcessing
{
public const string DirectPartBrepSource = "part_brep_direct";
public static List<ComponentHighlightImageRequest> BuildComponentHighlightImageRequests(SectionBrepReport report) => [];
public static List<FeatureHighlightImageRequest> BuildFeatureHighlightImageRequests(SectionBrepReport report)
{
var result = new List<FeatureHighlightImageRequest>();
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<AssemblyFaceEvidence>()
.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<ComponentContextImageRequest> 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<string> 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<AssemblyFaceEvidence> 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<string> BlockingFaceRefs) ChooseBestObliqueFeatureView(
MechanicalFeature feature,
IReadOnlyList<AssemblyFaceEvidence> targetFaces,
IReadOnlyList<AssemblyFaceEvidence> 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<string>();
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<AssemblyFaceEvidence> 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<AssemblyFaceEvidence> targetFaces,
IReadOnlyList<AssemblyFaceEvidence> 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<AssemblyFaceEvidence> 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<double[]> EnforcePreferredViewSide(List<double[]> 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<AssemblyFaceEvidence> 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<double[]> BuildObliqueCandidateDirections(bool includeSteepPlanarObliques)
{
var signs = new[] { -1.0, 1.0 };
var result = new List<double[]>();
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<double[]> 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<double[]> 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);
}