591 lines
31 KiB
C#
591 lines
31 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Drawing;
|
|
using System.Globalization;
|
|
using System.IO;
|
|
using System.Text.RegularExpressions;
|
|
using MultiWheelC.TrajectoryPlanning.CoarsePath;
|
|
using MultiWheelC.TrajectoryPlanning.Mapping;
|
|
using MultiWheelC.TrajectoryPlanning.PathSmoothing;
|
|
using MultiWheelC.TrajectoryPlanning.PathSmoothing.Output.Comparison;
|
|
using MultiWheelC.TrajectoryPlanning.PathSmoothing.Facade;
|
|
using MultiWheelC.TrajectoryPlanning.PathSmoothing.Test;
|
|
using MultiWheelC.TrajectoryPlanning.PathSmoothing.Output.Visualization;
|
|
|
|
internal static class Program
|
|
{
|
|
private static readonly byte[] PngSignature = { 137, 80, 78, 71, 13, 10, 26, 10 };
|
|
|
|
private static int Main(string[] arguments)
|
|
{
|
|
try
|
|
{
|
|
if (arguments.Length == 3 && arguments[0] == "--verify-local-g2-diagnostic")
|
|
{
|
|
VerifyLocalG2Diagnostic(arguments[1], arguments[2]);
|
|
Console.WriteLine("Local G2 diagnostic visualization verification completed.");
|
|
return 0;
|
|
}
|
|
if (arguments.Length == 4 && arguments[0] == "--export-local-g2-diagnostic")
|
|
{
|
|
ExportLocalG2Diagnostic(arguments[1], arguments[2], arguments[3]);
|
|
return 0;
|
|
}
|
|
if (arguments.Length == 3 && arguments[0] == "--export-fixtures")
|
|
{
|
|
ExportFixtureReports(arguments[1], arguments[2]);
|
|
return 0;
|
|
}
|
|
if (arguments.Length == 2 && arguments[0] == "--export-end-to-end")
|
|
{
|
|
ExportEndToEndReports(arguments[1]);
|
|
return 0;
|
|
}
|
|
Require(arguments.Length == 1 && File.Exists(arguments[0]), "A fixture path is required.");
|
|
Verify(arguments[0]);
|
|
Console.WriteLine("PNG verification host completed.");
|
|
return 0;
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
Console.Error.WriteLine(exception.ToString());
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
private static void ExportFixtureReports(string fixturePath, string outputDirectory)
|
|
{
|
|
Require(File.Exists(fixturePath), "Fixture path was not found.");
|
|
PrintReports(new PathSmoothingComparisonDemo().ExportFixtureReports(fixturePath, outputDirectory));
|
|
}
|
|
|
|
private static void ExportEndToEndReports(string outputDirectory)
|
|
{
|
|
PrintReports(new PathSmoothingComparisonDemo().ExportEndToEndReports(outputDirectory));
|
|
}
|
|
|
|
private static void ExportLocalG2Diagnostic(string fixturePath, string evidencePath, string outputDirectory)
|
|
{
|
|
Require(File.Exists(fixturePath), "Fixture path was not found.");
|
|
Require(File.Exists(evidencePath), "Diagnostic evidence path was not found.");
|
|
SmoothingReportExportResult report = new LocalG2DiagnosticVisualizationDemo().Export(
|
|
fixturePath,
|
|
evidencePath,
|
|
outputDirectory);
|
|
Require(report.Status == SmoothingReportExportStatus.Success, "Diagnostic report export failed: " + report.Reason);
|
|
Console.WriteLine("Diagnostic report=" + outputDirectory);
|
|
}
|
|
|
|
private static void VerifyLocalG2Diagnostic(string fixturePath, string evidencePath)
|
|
{
|
|
Require(File.Exists(fixturePath), "Fixture path was not found.");
|
|
Require(File.Exists(evidencePath), "Diagnostic evidence path was not found.");
|
|
string outputDirectory = Path.Combine(Path.GetTempPath(), "path-smoothing-local-g2-diagnostic-" + Guid.NewGuid().ToString("N"));
|
|
string badEvidencePath = Path.Combine(Path.GetTempPath(), "path-smoothing-local-g2-bad-" + Guid.NewGuid().ToString("N") + ".json");
|
|
string rejectedOutputDirectory = Path.Combine(Path.GetTempPath(), "path-smoothing-local-g2-rejected-" + Guid.NewGuid().ToString("N"));
|
|
string alteredFixturePath = Path.Combine(Path.GetTempPath(), "path-smoothing-local-g2-altered-fixture-" + Guid.NewGuid().ToString("N") + ".json");
|
|
string rejectedFixtureOutputDirectory = Path.Combine(Path.GetTempPath(), "path-smoothing-local-g2-fixture-rejected-" + Guid.NewGuid().ToString("N"));
|
|
try
|
|
{
|
|
SmoothingReportExportResult report = new LocalG2DiagnosticVisualizationDemo().Export(
|
|
fixturePath,
|
|
evidencePath,
|
|
outputDirectory);
|
|
Require(report.Status == SmoothingReportExportStatus.Success, "Diagnostic report export failed: " + report.Reason);
|
|
Require(report.SvgPaths.Count == 5 && report.PngPaths.Count == 5 && File.Exists(report.CsvPath),
|
|
"Diagnostic export must publish five SVGs, five PNGs and one CSV.");
|
|
Require(Path.GetFileName(report.SvgPaths[4]) == "05-local-g2-diagnostic-candidate.svg",
|
|
"Diagnostic SVG stem is incorrect.");
|
|
Require(Path.GetFileName(report.PngPaths[4]) == "05-local-g2-diagnostic-candidate.png",
|
|
"Diagnostic PNG stem is incorrect.");
|
|
string diagnosticSvg = File.ReadAllText(report.SvgPaths[4]);
|
|
Require(diagnosticSvg.Contains("data-series=\"raw\"") && diagnosticSvg.Contains("data-series=\"local-g2-diagnostic\""),
|
|
"Diagnostic SVG must contain raw and diagnostic samples.");
|
|
Require(diagnosticSvg.Contains("净空拒绝") && diagnosticSvg.Contains("未发布") &&
|
|
diagnosticSvg.Contains("严格输出=原始路径"),
|
|
"Diagnostic SVG must disclose clearance rejection, publication state, and strict output boundary.");
|
|
VerifyDiagnosticPointOverlay(diagnosticSvg, report.PngPaths[6]);
|
|
Require(!diagnosticSvg.Contains("violation-cross"),
|
|
"Clearance rejection must not be drawn as an obstacle collision.");
|
|
Require(File.ReadAllText(report.CsvPath).Contains("LocalG2Quintic,Unchanged"),
|
|
"CSV must retain the normal strict Local G2 row.");
|
|
for (int index = 0; index < report.PngPaths.Count; index++)
|
|
VerifyPng(File.ReadAllBytes(report.PngPaths[index]));
|
|
Require(!ContainsTemporaryFiles(outputDirectory), "Diagnostic export must not leave temporary files.");
|
|
|
|
File.Copy(fixturePath, alteredFixturePath);
|
|
File.AppendAllText(alteredFixturePath, " ");
|
|
bool alteredFixtureRejected = false;
|
|
try
|
|
{
|
|
new LocalG2DiagnosticVisualizationDemo().Export(alteredFixturePath, evidencePath, rejectedFixtureOutputDirectory);
|
|
}
|
|
catch (Exception)
|
|
{
|
|
alteredFixtureRejected = true;
|
|
}
|
|
Require(alteredFixtureRejected, "Fixture bytes that differ from diagnostic evidence must be rejected.");
|
|
Require(!Directory.Exists(rejectedFixtureOutputDirectory),
|
|
"Fixture hash mismatch must not create an output directory.");
|
|
|
|
File.WriteAllText(badEvidencePath, File.ReadAllText(evidencePath).Replace(
|
|
"3d05daee5a211b3e7aa0b77193423b5fa07d3135e241a4413be3518fc7efe563",
|
|
"0000000000000000000000000000000000000000000000000000000000000000"));
|
|
bool badEvidenceRejected = false;
|
|
try
|
|
{
|
|
new LocalG2DiagnosticVisualizationDemo().Export(fixturePath, badEvidencePath, rejectedOutputDirectory);
|
|
}
|
|
catch (Exception)
|
|
{
|
|
badEvidenceRejected = true;
|
|
}
|
|
Require(badEvidenceRejected, "Tampered diagnostic evidence must be rejected.");
|
|
Require(!Directory.Exists(rejectedOutputDirectory),
|
|
"Rejected diagnostic evidence must not create an output directory.");
|
|
}
|
|
finally
|
|
{
|
|
if (File.Exists(alteredFixturePath)) File.Delete(alteredFixturePath);
|
|
if (Directory.Exists(rejectedFixtureOutputDirectory)) Directory.Delete(rejectedFixtureOutputDirectory, true);
|
|
if (File.Exists(badEvidencePath)) File.Delete(badEvidencePath);
|
|
if (Directory.Exists(rejectedOutputDirectory)) Directory.Delete(rejectedOutputDirectory, true);
|
|
if (Directory.Exists(outputDirectory)) Directory.Delete(outputDirectory, true);
|
|
}
|
|
}
|
|
|
|
private static void PrintReports(IReadOnlyList<PathSmoothingComparisonScenarioResult> scenarios)
|
|
{
|
|
for (int scenarioIndex = 0; scenarioIndex < scenarios.Count; scenarioIndex++)
|
|
{
|
|
PathSmoothingComparisonScenarioResult scenario = scenarios[scenarioIndex];
|
|
if (scenario.Comparison == null)
|
|
{
|
|
Console.WriteLine(scenario.ScenarioId + " coarse=" + scenario.CoarsePathStatus + " " + scenario.Diagnostic);
|
|
continue;
|
|
}
|
|
PrintEntry(scenario.ScenarioId, scenario.Comparison.RawPathBaseline);
|
|
for (int entryIndex = 0; entryIndex < scenario.Comparison.Entries.Count; entryIndex++)
|
|
PrintEntry(scenario.ScenarioId, scenario.Comparison.Entries[entryIndex]);
|
|
Console.WriteLine(scenario.ScenarioId + " report=" + scenario.Report.Status + " " + scenario.Report.Reason);
|
|
}
|
|
}
|
|
|
|
private static void PrintEntry(string scenarioId, PathSmoothingComparisonEntry entry)
|
|
{
|
|
Console.WriteLine(string.Format(
|
|
CultureInfo.InvariantCulture,
|
|
"{0} {1} status={2} length={3:F4} peakKappa={4:F4} clearance={5:F4}",
|
|
scenarioId,
|
|
entry.IsRawPathBaseline ? "RawPath" : entry.Method.ToString(),
|
|
entry.Status,
|
|
entry.Metrics.PathLengthMeters,
|
|
entry.Metrics.MaximumAbsoluteVehicleCurvaturePerMeter,
|
|
entry.Metrics.MinimumBodyClearanceMeters));
|
|
}
|
|
|
|
private static void Verify(string fixturePath)
|
|
{
|
|
PathSmoothingComparisonRequest request = CreateComparisonRequest();
|
|
PathSmoothingComparisonResult comparison = new PathSmoothingComparisonService().Compare(request);
|
|
Require(!comparison.IsCancelled, "Fixture comparison was cancelled.");
|
|
Require(request.SmoothingRequest.CoarsePath.Count > 1, "Fixture coarse path is too short.");
|
|
|
|
CoarsePathPoint first = request.SmoothingRequest.CoarsePath[0];
|
|
CoarsePathPoint last = request.SmoothingRequest.CoarsePath[request.SmoothingRequest.CoarsePath.Count - 1];
|
|
var model = new SmoothingFigureModelBuilder().Build(
|
|
comparison,
|
|
request.SmoothingRequest.Map,
|
|
new Pose2D(first.X, first.Y, first.Heading),
|
|
new Pose2D(last.X, last.Y, last.Heading),
|
|
"straight",
|
|
"Straight");
|
|
|
|
VerifyObstacleRectanglesAreMerged(fixturePath, comparison, new Pose2D(first.X, first.Y, first.Heading), new Pose2D(last.X, last.Y, last.Heading));
|
|
VerifyFourFigureDefinitionContract(model);
|
|
VerifyNormalLocalG2Series(model);
|
|
VerifyPointOnlyRendererContract(model);
|
|
|
|
var resolver = new SmoothingFontResolver();
|
|
Require(resolver.TryResolve("SimSun", "Times New Roman", out SmoothingFontResolution fonts, out string fontReason),
|
|
"Required report fonts are unavailable: " + fontReason);
|
|
byte[] png;
|
|
using (fonts)
|
|
{
|
|
Require(fonts.ChineseFamilyName == "SimSun", "Chinese font must resolve to exact SimSun family.");
|
|
Require(fonts.LatinFamilyName == "Times New Roman", "Latin font must resolve to exact Times New Roman family.");
|
|
Require(resolver.MeasureMixedText(fonts, "粗路径 κ(s) X (m) −π", 9f).Width > 0f,
|
|
"Mixed Chinese/Latin sample must have nonempty measured bounds.");
|
|
png = new SmoothingPngRenderer().Render(model, fonts);
|
|
}
|
|
VerifyPng(png);
|
|
|
|
string outputDirectory = Path.Combine(Path.GetTempPath(), "path-smoothing-png-" + Guid.NewGuid().ToString("N"));
|
|
try
|
|
{
|
|
var exporter = new SmoothingReportExporter();
|
|
SmoothingReportExportResult report = exporter.Export(new SmoothingReportExportRequest
|
|
{
|
|
Model = model,
|
|
OutputDirectory = outputDirectory,
|
|
FileStem = "straight-report",
|
|
});
|
|
Require(report.Status == SmoothingReportExportStatus.Success, "Report export failed: " + report.Reason);
|
|
Require(report.SvgPaths.Count == 4 && report.PngPaths.Count == 4 && File.Exists(report.CsvPath),
|
|
"Successful export must publish four SVGs, four PNGs and one CSV.");
|
|
VerifyPublishedFourFigureFiles(report, outputDirectory);
|
|
Require(!ContainsTemporaryFiles(outputDirectory), "Successful export must not leave temporary files.");
|
|
|
|
string unavailableDirectory = Path.Combine(outputDirectory, "missing-font");
|
|
SmoothingReportExportResult unavailable = exporter.Export(new SmoothingReportExportRequest
|
|
{
|
|
Model = model,
|
|
OutputDirectory = unavailableDirectory,
|
|
FileStem = "must-not-write",
|
|
ChineseFontFamilyName = "Missing report font",
|
|
LatinFontFamilyName = "Times New Roman",
|
|
});
|
|
Require(unavailable.Status == SmoothingReportExportStatus.FontUnavailable,
|
|
"Missing exact font must report FontUnavailable.");
|
|
Require(!Directory.Exists(unavailableDirectory), "Missing-font export must remain atomic and create no output directory.");
|
|
}
|
|
finally
|
|
{
|
|
if (Directory.Exists(outputDirectory)) Directory.Delete(outputDirectory, true);
|
|
}
|
|
}
|
|
|
|
private static PathSmoothingComparisonRequest CreateComparisonRequest()
|
|
{
|
|
PlanningMapBuildResult mapResult = new PlanningMapFactory().Create(new PlanningMapRequest
|
|
{
|
|
Bounds = new MapBoundsMm(0f, 5000f, 0f, 5000f),
|
|
ResolutionMm = 50f,
|
|
AllowExplicitEmptyMap = true,
|
|
});
|
|
Require(mapResult.Succeeded && mapResult.Map != null, "PNG verification map must be created.");
|
|
var vehicle = new VehicleParameters
|
|
{
|
|
LengthMeters = 0.20d,
|
|
WidthMeters = 0.20d,
|
|
SafetyMarginMeters = 0d,
|
|
MaximumCurvaturePerMeter = 100d,
|
|
};
|
|
var coarsePath = new List<CoarsePathPoint>
|
|
{
|
|
CreatePoint(0.5d, 0.5d, 0d),
|
|
CreatePoint(1.0d, 0.5d, 0.5d),
|
|
CreatePoint(1.0d, 1.0d, 1.0d),
|
|
CreatePoint(1.5d, 1.0d, 1.5d),
|
|
};
|
|
var segments = new List<PathSegment>
|
|
{
|
|
new PathSegment(0, TravelDirection.Forward, 0, coarsePath.Count - 1, false, false),
|
|
};
|
|
return new PathSmoothingComparisonRequest(new PathSmoothingRequest(
|
|
coarsePath,
|
|
segments,
|
|
mapResult.Map,
|
|
vehicle,
|
|
new PathSmoothingConfiguration()));
|
|
}
|
|
|
|
private static void VerifyFourFigureDefinitionContract(SmoothingFigureModel model)
|
|
{
|
|
SmoothingFigureSet figureSet = new SmoothingFigureSetBuilder().Build(model);
|
|
var stems = new List<string>();
|
|
for (int index = 0; index < figureSet.Figures.Count; index++) stems.Add(figureSet.Figures[index].FileStem);
|
|
string expected = string.Join(",", new[]
|
|
{
|
|
"01-coarse-path-overview",
|
|
"02-all-paths-comparison",
|
|
"03-local-g2-overview",
|
|
"04-curvature-comparison",
|
|
});
|
|
Require(string.Join(",", stems) == expected, "Four-figure report stems must be stable and ordered.");
|
|
Require(!figureSet.Figures[1].ShowsMapContext, "All-path comparison must contain only trajectories, axes and legend.");
|
|
Require(figureSet.Figures[2].ShowsMapContext && figureSet.Figures[2].Series[0].Opacity < 1d,
|
|
"Local G2 figure must retain a faded raw-path map reference.");
|
|
Require(figureSet.Figures[0].WorldScalePointsPerMeter > 0d &&
|
|
Math.Abs((figureSet.Figures[0].WorldXMaxMeters - figureSet.Figures[0].WorldXMinMeters) /
|
|
(figureSet.Figures[0].WorldYMaxMeters - figureSet.Figures[0].WorldYMinMeters) -
|
|
figureSet.Figures[0].PlotWidthPoints / figureSet.Figures[0].PlotHeightPoints) < 0.000001d,
|
|
"Overhead figures must preserve equal X/Y scale.");
|
|
Require(figureSet.Figures[0].LegendYPoints -
|
|
(figureSet.Figures[0].PlotYPoints + figureSet.Figures[0].PlotHeightPoints + 31d) >= 8d,
|
|
"Legend must leave vertical clearance below the X-axis unit label.");
|
|
}
|
|
|
|
private static void VerifyNormalLocalG2Series(SmoothingFigureModel model)
|
|
{
|
|
Require(model.Series.Count == 2, "Normal comparison model must contain only raw-path and Local G2 series.");
|
|
SmoothingFigureSeries localG2 = null;
|
|
int count = 0;
|
|
for (int index = 0; index < model.Series.Count; index++)
|
|
{
|
|
if (model.Series[index].Key != "local-g2") continue;
|
|
localG2 = model.Series[index];
|
|
count++;
|
|
}
|
|
Require(count == 1, "Normal comparison model must include exactly one Local G2 series.");
|
|
Require(Enum.IsDefined(typeof(PathSmoothingStatus), localG2.Status), "Local G2 must retain a defined smoothing status.");
|
|
if (localG2.IsCurveVisible)
|
|
Require(localG2.Points.Count >= 2, "Visible Local G2 output must include at least two samples.");
|
|
}
|
|
|
|
private static void VerifyObstacleRectanglesAreMerged(string fixturePath, PathSmoothingComparisonResult comparison, Pose2D start, Pose2D goal)
|
|
{
|
|
IReadOnlyList<SmoothingScenarioFixture> fixtures = SmoothingScenarioFixtureLoader.LoadAndVerify(fixturePath);
|
|
IReadOnlyList<PathSmoothingComparisonRequest> requests = SmoothingScenarioFactory.CreateFixtureRequests(fixturePath);
|
|
int rectangleIndex = -1;
|
|
for (int index = 0; index < fixtures.Count; index++)
|
|
{
|
|
if (fixtures[index].Id == "rectangle-detour") { rectangleIndex = index; break; }
|
|
}
|
|
Require(rectangleIndex >= 0, "Fixture suite must include rectangle-detour for obstacle rendering verification.");
|
|
SmoothingFigureModel model = new SmoothingFigureModelBuilder().Build(
|
|
comparison, requests[rectangleIndex].SmoothingRequest.Map, start, goal, "obstacle", "obstacle");
|
|
Require(model.Obstacles.Count > 0, "Rectangle-detour fixture must produce report obstacles.");
|
|
for (int firstIndex = 0; firstIndex < model.Obstacles.Count; firstIndex++)
|
|
{
|
|
SmoothingFigureObstacle first = model.Obstacles[firstIndex];
|
|
for (int secondIndex = firstIndex + 1; secondIndex < model.Obstacles.Count; secondIndex++)
|
|
{
|
|
SmoothingFigureObstacle second = model.Obstacles[secondIndex];
|
|
bool matchingColumn = Math.Abs(first.X - second.X) < 0.0000001d && Math.Abs(first.Width - second.Width) < 0.0000001d;
|
|
bool verticallyAdjacent = Math.Abs((first.Y + first.Height) - second.Y) < 0.0000001d ||
|
|
Math.Abs((second.Y + second.Height) - first.Y) < 0.0000001d;
|
|
Require(!(matchingColumn && verticallyAdjacent), "Adjacent occupied rows must merge into a single obstacle rectangle.");
|
|
}
|
|
}
|
|
}
|
|
|
|
private static void VerifyPointOnlyRendererContract(SmoothingFigureModel model)
|
|
{
|
|
SmoothingFigureDefinition figure = new SmoothingFigureSetBuilder().Build(model).Figures[1];
|
|
string svg = new SmoothingSvgRenderer().Render(figure);
|
|
Require(svg.Contains("class=\"trajectory-point\""), "Trajectory samples must render as discrete SVG point markers.");
|
|
Require(!svg.Contains("stroke-dasharray") && !svg.Contains("-path\""), "Trajectory SVG output must not use dashed or joined path strokes.");
|
|
Require(svg.Contains("X (m)") && svg.Contains("Y (m)"), "Overhead SVG must label metre coordinate axes.");
|
|
}
|
|
|
|
private static void VerifyDiagnosticPointOverlay(string svg, string pngPath)
|
|
{
|
|
const string rawHeader = "<g class=\"trajectory-series\" data-series=\"raw\" fill=\"#4D4D4D\" opacity=\"1\">";
|
|
const string diagnosticHeader = "<g class=\"trajectory-series\" data-series=\"local-g2-diagnostic\" fill=\"#B1373E\" opacity=\"1\">";
|
|
Require(svg.Contains(rawHeader), "Diagnostic raw series must render as opaque gray outer dots.");
|
|
Require(svg.Contains(diagnosticHeader), "Diagnostic candidate must render as opaque red inner dots.");
|
|
|
|
IReadOnlyList<SvgTrajectoryPoint> rawPoints = ReadSvgTrajectoryPoints(svg, "raw");
|
|
IReadOnlyList<SvgTrajectoryPoint> diagnosticPoints = ReadSvgTrajectoryPoints(svg, "local-g2-diagnostic");
|
|
Require(rawPoints.Count == 88, "Diagnostic SVG must retain the original 88 raw sample centers.");
|
|
Require(diagnosticPoints.Count == 88, "Diagnostic SVG must retain the original 88 candidate sample centers.");
|
|
for (int index = 0; index < rawPoints.Count; index++)
|
|
Require(rawPoints[index].Radius == "2.1", "Diagnostic raw points must use a 2.1 point radius.");
|
|
for (int index = 0; index < diagnosticPoints.Count; index++)
|
|
Require(diagnosticPoints[index].Radius == "1.35", "Diagnostic candidate points must use a 1.35 point radius.");
|
|
|
|
var diagnosticIndicesByCenter = new Dictionary<string, int>(StringComparer.Ordinal);
|
|
for (int index = 0; index < diagnosticPoints.Count; index++)
|
|
diagnosticIndicesByCenter[CenterKey(diagnosticPoints[index])] = index;
|
|
int sharedCount = 0;
|
|
bool hasNonEndpointSharedCenter = false;
|
|
for (int index = 0; index < rawPoints.Count; index++)
|
|
{
|
|
if (!diagnosticIndicesByCenter.TryGetValue(CenterKey(rawPoints[index]), out int diagnosticIndex)) continue;
|
|
sharedCount++;
|
|
if (index > 0 && index < rawPoints.Count - 1 && diagnosticIndex > 0 && diagnosticIndex < diagnosticPoints.Count - 1)
|
|
hasNonEndpointSharedCenter = true;
|
|
}
|
|
Require(sharedCount == 81 && hasNonEndpointSharedCenter,
|
|
"Diagnostic overlay must retain exact shared non-endpoint raw and candidate centers without coordinate offsets.");
|
|
VerifyDiagnosticOverlayPixels(pngPath, rawPoints, diagnosticPoints, diagnosticIndicesByCenter);
|
|
}
|
|
|
|
private static IReadOnlyList<SvgTrajectoryPoint> ReadSvgTrajectoryPoints(string svg, string seriesKey)
|
|
{
|
|
string marker = "data-series=\"" + seriesKey + "\"";
|
|
int seriesMarker = svg.IndexOf(marker, StringComparison.Ordinal);
|
|
Require(seriesMarker >= 0, "Diagnostic SVG series is missing: " + seriesKey);
|
|
int seriesStart = svg.LastIndexOf("<g", seriesMarker, StringComparison.Ordinal);
|
|
int seriesEnd = svg.IndexOf("</g>", seriesMarker, StringComparison.Ordinal);
|
|
Require(seriesStart >= 0 && seriesEnd > seriesStart, "Diagnostic SVG series markup is invalid: " + seriesKey);
|
|
string series = svg.Substring(seriesStart, seriesEnd - seriesStart);
|
|
MatchCollection matches = Regex.Matches(series,
|
|
"<circle class=\"trajectory-point\" cx=\"(?<x>[^\"]+)\" cy=\"(?<y>[^\"]+)\" r=\"(?<r>[^\"]+)\"/>",
|
|
RegexOptions.CultureInvariant);
|
|
var points = new List<SvgTrajectoryPoint>(matches.Count);
|
|
for (int index = 0; index < matches.Count; index++)
|
|
points.Add(new SvgTrajectoryPoint(matches[index].Groups["x"].Value, matches[index].Groups["y"].Value, matches[index].Groups["r"].Value));
|
|
return points;
|
|
}
|
|
|
|
private static void VerifyDiagnosticOverlayPixels(
|
|
string pngPath,
|
|
IReadOnlyList<SvgTrajectoryPoint> rawPoints,
|
|
IReadOnlyList<SvgTrajectoryPoint> diagnosticPoints,
|
|
IReadOnlyDictionary<string, int> diagnosticIndicesByCenter)
|
|
{
|
|
const double pixelsPerPoint = 600d / 72d;
|
|
const double annulusRadiusPoints = 1.70d;
|
|
using (var bitmap = new Bitmap(pngPath))
|
|
{
|
|
for (int rawIndex = 1; rawIndex < rawPoints.Count - 1; rawIndex++)
|
|
{
|
|
SvgTrajectoryPoint raw = rawPoints[rawIndex];
|
|
if (!diagnosticIndicesByCenter.TryGetValue(CenterKey(raw), out int diagnosticIndex) ||
|
|
diagnosticIndex == 0 || diagnosticIndex == diagnosticPoints.Count - 1) continue;
|
|
int centerX = (int)Math.Round(raw.XPoints * pixelsPerPoint, MidpointRounding.AwayFromZero);
|
|
int centerY = (int)Math.Round(raw.YPoints * pixelsPerPoint, MidpointRounding.AwayFromZero);
|
|
if (!IsDiagnosticRed(bitmap.GetPixel(centerX, centerY))) continue;
|
|
|
|
for (int angleIndex = 0; angleIndex < 24; angleIndex++)
|
|
{
|
|
double angle = 2d * Math.PI * angleIndex / 24d;
|
|
int annulusX = (int)Math.Round(centerX + Math.Cos(angle) * annulusRadiusPoints * pixelsPerPoint, MidpointRounding.AwayFromZero);
|
|
int annulusY = (int)Math.Round(centerY + Math.Sin(angle) * annulusRadiusPoints * pixelsPerPoint, MidpointRounding.AwayFromZero);
|
|
if (annulusX < 0 || annulusX >= bitmap.Width || annulusY < 0 || annulusY >= bitmap.Height) continue;
|
|
if (IsRawGray(bitmap.GetPixel(annulusX, annulusY))) return;
|
|
}
|
|
}
|
|
}
|
|
throw new InvalidOperationException("A shared non-endpoint sample must have a red center and gray annulus.");
|
|
}
|
|
|
|
private static bool IsDiagnosticRed(Color color)
|
|
{
|
|
return color.R >= 140 && color.G <= 95 && color.B <= 100 && color.R >= color.G + 60;
|
|
}
|
|
|
|
private static bool IsRawGray(Color color)
|
|
{
|
|
return color.R >= 45 && color.R <= 110 && Math.Abs(color.R - color.G) <= 8 && Math.Abs(color.G - color.B) <= 8;
|
|
}
|
|
|
|
private static string CenterKey(SvgTrajectoryPoint point) { return point.X + "|" + point.Y; }
|
|
|
|
private readonly struct SvgTrajectoryPoint
|
|
{
|
|
public SvgTrajectoryPoint(string x, string y, string radius)
|
|
{
|
|
X = x;
|
|
Y = y;
|
|
Radius = radius;
|
|
XPoints = double.Parse(x, CultureInfo.InvariantCulture);
|
|
YPoints = double.Parse(y, CultureInfo.InvariantCulture);
|
|
}
|
|
|
|
public string X { get; }
|
|
public string Y { get; }
|
|
public string Radius { get; }
|
|
public double XPoints { get; }
|
|
public double YPoints { get; }
|
|
}
|
|
|
|
private static void VerifyPublishedFourFigureFiles(SmoothingReportExportResult report, string outputDirectory)
|
|
{
|
|
var expectedStems = new[]
|
|
{
|
|
"01-coarse-path-overview",
|
|
"02-all-paths-comparison",
|
|
"03-local-g2-overview",
|
|
"04-curvature-comparison",
|
|
};
|
|
VerifyPublishedPaths(report.SvgPaths, expectedStems, ".svg", outputDirectory);
|
|
VerifyPublishedPaths(report.PngPaths, expectedStems, ".png", outputDirectory);
|
|
Require(!File.Exists(Path.Combine(outputDirectory, "comparison.svg")) && !File.Exists(Path.Combine(outputDirectory, "comparison.png")),
|
|
"Four-figure export must not leave legacy composite comparison images.");
|
|
}
|
|
|
|
private static void VerifyPublishedPaths(IReadOnlyList<string> paths, string[] expectedStems, string extension, string outputDirectory)
|
|
{
|
|
var actual = new List<string>();
|
|
for (int index = 0; index < paths.Count; index++) actual.Add(paths[index]);
|
|
Require(actual.Count == expectedStems.Length, "Four-figure export must publish four " + extension + " files.");
|
|
for (int index = 0; index < expectedStems.Length; index++)
|
|
{
|
|
string expected = Path.Combine(outputDirectory, expectedStems[index] + extension);
|
|
Require(actual[index] == expected && File.Exists(actual[index]), "Published " + extension + " path must match the stable figure stem.");
|
|
}
|
|
}
|
|
|
|
private static CoarsePathPoint CreatePoint(double x, double y, double arcLength)
|
|
{
|
|
return new CoarsePathPoint(
|
|
x, y, 0d, 0d, arcLength, TravelDirection.Forward, 0d, 1d, false, CoarsePathPointSource.Start);
|
|
}
|
|
|
|
private static bool ContainsTemporaryFiles(string directory)
|
|
{
|
|
foreach (string ignored in Directory.EnumerateFiles(directory, "*.tmp")) return true;
|
|
return false;
|
|
}
|
|
|
|
private static void VerifyPng(byte[] png)
|
|
{
|
|
Require(png != null && png.Length > PngSignature.Length, "PNG output is empty.");
|
|
byte[] data = png ?? throw new InvalidOperationException("PNG output is empty.");
|
|
for (int index = 0; index < PngSignature.Length; index++)
|
|
Require(data[index] == PngSignature[index], "PNG signature is invalid.");
|
|
|
|
bool sawHeader = false;
|
|
bool sawPhysicalResolution = false;
|
|
int offset = PngSignature.Length;
|
|
while (offset < data.Length)
|
|
{
|
|
Require(offset + 12 <= data.Length, "PNG chunk header is truncated.");
|
|
int length = checked((int)ReadUInt32BigEndian(data, offset));
|
|
int dataStart = offset + 8;
|
|
int crcStart = checked(dataStart + length);
|
|
Require(crcStart + 4 <= data.Length, "PNG chunk data is truncated.");
|
|
uint expectedCrc = ReadUInt32BigEndian(data, crcStart);
|
|
uint actualCrc = ComputeCrc32(data, offset + 4, length + 4);
|
|
Require(expectedCrc == actualCrc, "PNG chunk CRC is invalid.");
|
|
|
|
string type = System.Text.Encoding.ASCII.GetString(data, offset + 4, 4);
|
|
if (type == "IHDR")
|
|
{
|
|
Require(length == 13, "IHDR length must be 13.");
|
|
Require(ReadUInt32BigEndian(data, dataStart) == SmoothingPngRenderer.WidthPixels, "PNG width must be 4296.");
|
|
Require(ReadUInt32BigEndian(data, dataStart + 4) == SmoothingPngRenderer.HeightPixels, "PNG height must be 3120.");
|
|
sawHeader = true;
|
|
}
|
|
else if (type == "pHYs")
|
|
{
|
|
Require(length == 9, "pHYs length must be 9.");
|
|
Require(ReadUInt32BigEndian(data, dataStart) == SmoothingPngRenderer.PixelsPerMeter,
|
|
"PNG horizontal density must be 23622 pixels/meter.");
|
|
Require(ReadUInt32BigEndian(data, dataStart + 4) == SmoothingPngRenderer.PixelsPerMeter,
|
|
"PNG vertical density must be 23622 pixels/meter.");
|
|
Require(data[dataStart + 8] == 1, "PNG pHYs unit must be meter.");
|
|
sawPhysicalResolution = true;
|
|
}
|
|
offset = crcStart + 4;
|
|
}
|
|
Require(sawHeader && sawPhysicalResolution, "PNG must contain IHDR and pHYs chunks.");
|
|
}
|
|
|
|
private static uint ReadUInt32BigEndian(byte[] data, int offset)
|
|
{
|
|
return ((uint)data[offset] << 24) | ((uint)data[offset + 1] << 16) |
|
|
((uint)data[offset + 2] << 8) | data[offset + 3];
|
|
}
|
|
|
|
private static uint ComputeCrc32(byte[] data, int offset, int length)
|
|
{
|
|
uint crc = 0xffffffffu;
|
|
for (int index = 0; index < length; index++)
|
|
{
|
|
crc ^= data[offset + index];
|
|
for (int bit = 0; bit < 8; bit++)
|
|
crc = (crc & 1u) == 0u ? crc >> 1 : (crc >> 1) ^ 0xedb88320u;
|
|
}
|
|
return crc ^ 0xffffffffu;
|
|
}
|
|
|
|
private static void Require(bool condition, string message)
|
|
{
|
|
if (!condition) throw new InvalidOperationException(message);
|
|
}
|
|
}
|