506 lines
20 KiB
C#
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);
|
|
}
|