diff --git a/Quantum.IO/TrackLayout/V2/TrackLayoutPackageV2Dtos.cs b/Quantum.IO/TrackLayout/V2/TrackLayoutPackageV2Dtos.cs new file mode 100644 index 0000000..381a819 --- /dev/null +++ b/Quantum.IO/TrackLayout/V2/TrackLayoutPackageV2Dtos.cs @@ -0,0 +1,126 @@ +using System; +using System.Text.Json.Serialization; + +namespace Quantum.IO.TrackLayout.V2 +{ + /// + /// Versioned backend-only JSON DTO for authored coaster track layouts. + /// + public sealed class TrackLayoutPackageV2Dto + { + public const string ContractName = "quantum.track_layout_package"; + + public const int ContractVersion = 2; + + public string Contract { get; set; } = ContractName; + + public int Version { get; set; } = ContractVersion; + + public TrackLayoutMetadataV2Dto Metadata { get; set; } = new TrackLayoutMetadataV2Dto(); + + public TrackStartPoseV2Dto StartPose { get; set; } = new TrackStartPoseV2Dto(); + + public TrackLayoutSectionV2Dto[] Sections { get; set; } = + Array.Empty(); + + public TrackBankingV2Dto? Banking { get; set; } + + public TrackHeartlineV2Dto? Heartline { get; set; } + } + + public sealed class TrackLayoutMetadataV2Dto + { + public string Units { get; set; } = "meters"; + + public string? SourceName { get; set; } + + public string? LayoutId { get; set; } + } + + public sealed class TrackStartPoseV2Dto + { + public TrackLayoutVector3dV2Dto Position { get; set; } = new TrackLayoutVector3dV2Dto(); + + public TrackLayoutVector3dV2Dto Tangent { get; set; } = + new TrackLayoutVector3dV2Dto { X = 1.0 }; + + public TrackLayoutVector3dV2Dto Normal { get; set; } = + new TrackLayoutVector3dV2Dto { Y = 1.0 }; + + public TrackLayoutVector3dV2Dto Binormal { get; set; } = + new TrackLayoutVector3dV2Dto { Z = 1.0 }; + } + + public sealed class TrackLayoutVector3dV2Dto + { + public double X { get; set; } + + public double Y { get; set; } + + public double Z { get; set; } + } + + public sealed class TrackLayoutSectionV2Dto + { + public string Kind { get; set; } = string.Empty; + + public string Id { get; set; } = string.Empty; + + public double Length { get; set; } + + public double RollRadians { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public double? Radius { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public double? StartCurvature { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public double? EndCurvature { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? InterpolationMode { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? Degree { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public TrackLayoutVector3dV2Dto[]? ControlPoints { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public double[]? Weights { get; set; } + } + + public sealed class TrackBankingV2Dto + { + public TrackBankingKeyV2Dto[] Keys { get; set; } = + Array.Empty(); + } + + public sealed class TrackBankingKeyV2Dto + { + public double Distance { get; set; } + + public double RollRadians { get; set; } + + public string InterpolationToNext { get; set; } = + TrackLayoutPackageV2Vocabulary.BankingInterpolationConstant; + } + + public sealed class TrackHeartlineV2Dto + { + public string Kind { get; set; } = + TrackLayoutPackageV2Vocabulary.HeartlineKindConstantOffset; + + public string DistanceDomain { get; set; } = + TrackLayoutPackageV2Vocabulary.HeartlineDistanceDomainCenterlineStation; + + public string AxisSource { get; set; } = + TrackLayoutPackageV2Vocabulary.HeartlineAxisSourceSampledFrame; + + public double NormalOffset { get; set; } + + public double LateralOffset { get; set; } + } +} diff --git a/Quantum.IO/TrackLayout/V2/TrackLayoutPackageV2Json.cs b/Quantum.IO/TrackLayout/V2/TrackLayoutPackageV2Json.cs new file mode 100644 index 0000000..8be6d99 --- /dev/null +++ b/Quantum.IO/TrackLayout/V2/TrackLayoutPackageV2Json.cs @@ -0,0 +1,61 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Quantum.IO.TrackLayout.V2 +{ + /// + /// JSON reader/writer for the TrackLayoutPackageV2 authored-layout contract. + /// + public static class TrackLayoutPackageV2Json + { + private static readonly JsonSerializerOptions CompactOptions = CreateOptions(indented: false); + private static readonly JsonSerializerOptions IndentedOptions = CreateOptions(indented: true); + + public static string Serialize(TrackLayoutPackageV2Dto dto, bool indented = false) + { + if (dto == null) + { + throw new ArgumentNullException(nameof(dto)); + } + + return JsonSerializer.Serialize(dto, indented ? IndentedOptions : CompactOptions); + } + + public static TrackLayoutPackageV2Dto Deserialize(string json) + { + if (json == null) + { + throw new ArgumentNullException(nameof(json)); + } + + TrackLayoutPackageV2Dto? dto; + + try + { + dto = JsonSerializer.Deserialize(json, CompactOptions); + } + catch (JsonException ex) + { + throw new JsonException("Failed to deserialize TrackLayoutPackageV2Dto: malformed JSON.", ex); + } + + if (dto == null) + { + throw new JsonException("Failed to deserialize TrackLayoutPackageV2Dto: JSON payload was null."); + } + + return dto; + } + + private static JsonSerializerOptions CreateOptions(bool indented) + { + return new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = indented, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow + }; + } + } +} diff --git a/Quantum.IO/TrackLayout/V2/TrackLayoutPackageV2Validator.cs b/Quantum.IO/TrackLayout/V2/TrackLayoutPackageV2Validator.cs new file mode 100644 index 0000000..706cd47 --- /dev/null +++ b/Quantum.IO/TrackLayout/V2/TrackLayoutPackageV2Validator.cs @@ -0,0 +1,1219 @@ +using System; +using System.Collections.Generic; +using System.Globalization; + +namespace Quantum.IO.TrackLayout.V2 +{ + public enum TrackLayoutPackageV2ValidationCode + { + InvalidContract = 0, + InvalidVersion = 1, + MissingMetadata = 2, + MissingStartPose = 3, + MissingSections = 4, + EmptySections = 5, + MissingObject = 6, + MissingRequiredField = 7, + MissingSectionId = 8, + DuplicateSectionId = 9, + UnknownSectionKind = 10, + UnexpectedSectionField = 11, + NonFiniteNumber = 12, + NonPositiveLength = 13, + InvalidRadius = 14, + InvalidCurvatureInterpolation = 15, + InvalidStartPoseBasis = 16, + InvalidSpatialDegree = 17, + InvalidSpatialControlPoints = 18, + InvalidSpatialWeights = 19, + InvalidSpatialStartContract = 20, + InvalidBankingKeyCount = 21, + InvalidBankingKeyOrder = 22, + InvalidBankingInterpolation = 23, + InvalidBankingDomain = 24, + InvalidHeartlineKind = 25, + InvalidHeartlineDistanceDomain = 26, + InvalidHeartlineAxisSource = 27 + } + + public sealed class TrackLayoutPackageV2ValidationDiagnostic + { + public TrackLayoutPackageV2ValidationDiagnostic( + TrackLayoutPackageV2ValidationCode code, + string path, + string message, + double? value = null, + double? expected = null, + double? tolerance = null) + { + Code = code; + Path = path ?? string.Empty; + Message = message ?? string.Empty; + Value = value; + Expected = expected; + Tolerance = tolerance; + } + + public TrackLayoutPackageV2ValidationCode Code { get; } + + public string Path { get; } + + public string Message { get; } + + public double? Value { get; } + + public double? Expected { get; } + + public double? Tolerance { get; } + } + + internal static class TrackLayoutPackageV2DiagnosticMessages + { + public static string Section( + TrackLayoutSectionV2Dto section, + int sectionIndex, + string detail) + { + return WithContext(detail, SectionContext(section, sectionIndex)); + } + + public static string DuplicateSectionId( + TrackLayoutSectionV2Dto section, + int sectionIndex, + int previousSectionIndex) + { + return WithContext( + "Section IDs must be unique using ordinal comparison.", + SectionContext(section, sectionIndex)) + + " Previous section index " + + previousSectionIndex.ToString(CultureInfo.InvariantCulture) + + " used the same id."; + } + + public static string SectionContext( + TrackLayoutSectionV2Dto section, + int sectionIndex) + { + string context = "Section index " + sectionIndex.ToString(CultureInfo.InvariantCulture); + if (!string.IsNullOrWhiteSpace(section.Id)) + { + context += ", id '" + section.Id + "'"; + } + + if (!string.IsNullOrWhiteSpace(section.Kind)) + { + context += ", kind '" + section.Kind + "'"; + } + + return context + "."; + } + + public static string BankingKey(int keyIndex, string detail) + { + return WithContext(detail, BankingKeyContext(keyIndex)); + } + + public static string BankingKeyOrder( + int keyIndex, + int previousKeyIndex, + double previousDistance) + { + return WithContext( + "Banking key distances must be strictly increasing.", + "Banking key index " + + keyIndex.ToString(CultureInfo.InvariantCulture) + + "; previous key index " + + previousKeyIndex.ToString(CultureInfo.InvariantCulture) + + " distance " + + FormatDouble(previousDistance) + + "."); + } + + public static string BankingFinalDomain( + int keyIndex, + double expectedTotalLength) + { + return WithContext( + "Authored banking must end exactly at total section length.", + "Banking key index " + + keyIndex.ToString(CultureInfo.InvariantCulture) + + "; expected total length " + + FormatDouble(expectedTotalLength) + + "."); + } + + public static string WithContext(string detail, string? context) + { + if (string.IsNullOrWhiteSpace(context)) + { + return detail; + } + + if (string.IsNullOrWhiteSpace(detail)) + { + return context; + } + + return detail + " " + context; + } + + public static string BankingKeyContext(int keyIndex) + { + return "Banking key index " + keyIndex.ToString(CultureInfo.InvariantCulture) + "."; + } + + private static string FormatDouble(double value) + { + return value.ToString("R", CultureInfo.InvariantCulture); + } + } + + public static class TrackLayoutPackageV2Validator + { + private const double MinimumAxisMagnitude = 1e-9; + private const double UnitLengthTolerance = 1e-9; + private const double OrthogonalityTolerance = 1e-9; + private const double HandednessTolerance = 1e-9; + private const double MinimumSpatialStartTangentMagnitude = 1e-9; + private const double SpatialStartTangentAlignmentTolerance = 1e-9; + + public static IReadOnlyList Validate( + TrackLayoutPackageV2Dto dto) + { + if (dto == null) + { + throw new ArgumentNullException(nameof(dto)); + } + + var diagnostics = new List(); + + ValidateContract(dto, diagnostics); + ValidateMetadata(dto.Metadata, diagnostics); + ValidateStartPose(dto.StartPose, diagnostics); + + double totalLength = 0.0; + bool totalLengthValid = ValidateSections(dto.Sections, diagnostics, out totalLength); + ValidateBanking(dto.Banking, totalLength, totalLengthValid, diagnostics); + ValidateHeartline(dto.Heartline, diagnostics); + + return diagnostics.ToArray(); + } + + public static bool TryValidate( + TrackLayoutPackageV2Dto dto, + out IReadOnlyList diagnostics) + { + diagnostics = Validate(dto); + return diagnostics.Count == 0; + } + + private static void ValidateContract( + TrackLayoutPackageV2Dto dto, + List diagnostics) + { + if (!string.Equals(dto.Contract, TrackLayoutPackageV2Dto.ContractName, StringComparison.Ordinal)) + { + diagnostics.Add( + new TrackLayoutPackageV2ValidationDiagnostic( + TrackLayoutPackageV2ValidationCode.InvalidContract, + "contract", + "Contract name must match TrackLayoutPackageV2.")); + } + + if (dto.Version != TrackLayoutPackageV2Dto.ContractVersion) + { + diagnostics.Add( + new TrackLayoutPackageV2ValidationDiagnostic( + TrackLayoutPackageV2ValidationCode.InvalidVersion, + "version", + "Version must match TrackLayoutPackageV2.", + dto.Version, + TrackLayoutPackageV2Dto.ContractVersion)); + } + } + + private static void ValidateMetadata( + TrackLayoutMetadataV2Dto? metadata, + List diagnostics) + { + if (metadata == null) + { + diagnostics.Add( + new TrackLayoutPackageV2ValidationDiagnostic( + TrackLayoutPackageV2ValidationCode.MissingMetadata, + "metadata", + "Metadata object is required.")); + return; + } + + if (string.IsNullOrWhiteSpace(metadata.Units)) + { + AddMissingField( + "metadata.units", + "Metadata units are required and must not be blank.", + diagnostics); + } + } + + private static void ValidateStartPose( + TrackStartPoseV2Dto? startPose, + List diagnostics) + { + if (startPose == null) + { + diagnostics.Add( + new TrackLayoutPackageV2ValidationDiagnostic( + TrackLayoutPackageV2ValidationCode.MissingStartPose, + "startPose", + "Start pose object is required.")); + return; + } + + ValidateFiniteVector(startPose.Position, "startPose.position", diagnostics); + bool tangentValid = ValidateBasisVector(startPose.Tangent, "startPose.tangent", diagnostics); + bool normalValid = ValidateBasisVector(startPose.Normal, "startPose.normal", diagnostics); + bool binormalValid = ValidateBasisVector(startPose.Binormal, "startPose.binormal", diagnostics); + + if (!tangentValid || !normalValid || !binormalValid) + { + return; + } + + ValidateUnitLength(startPose.Tangent!, "startPose.tangent", diagnostics); + ValidateUnitLength(startPose.Normal!, "startPose.normal", diagnostics); + ValidateUnitLength(startPose.Binormal!, "startPose.binormal", diagnostics); + ValidateOrthogonality( + startPose.Tangent!, + startPose.Normal!, + "startPose.tangentNormalDot", + diagnostics); + ValidateOrthogonality( + startPose.Tangent!, + startPose.Binormal!, + "startPose.tangentBinormalDot", + diagnostics); + ValidateOrthogonality( + startPose.Normal!, + startPose.Binormal!, + "startPose.normalBinormalDot", + diagnostics); + + double handedness = Dot(Cross(startPose.Tangent!, startPose.Normal!), startPose.Binormal!); + if (handedness < 1.0 - HandednessTolerance) + { + diagnostics.Add( + new TrackLayoutPackageV2ValidationDiagnostic( + TrackLayoutPackageV2ValidationCode.InvalidStartPoseBasis, + "startPose.handedness", + "Start pose basis must satisfy binormal ~= tangent x normal.", + handedness, + 1.0, + HandednessTolerance)); + } + } + + private static bool ValidateSections( + TrackLayoutSectionV2Dto[]? sections, + List diagnostics, + out double totalLength) + { + totalLength = 0.0; + bool totalLengthValid = true; + + if (sections == null) + { + diagnostics.Add( + new TrackLayoutPackageV2ValidationDiagnostic( + TrackLayoutPackageV2ValidationCode.MissingSections, + "sections", + "Sections collection is required.")); + return false; + } + + if (sections.Length == 0) + { + diagnostics.Add( + new TrackLayoutPackageV2ValidationDiagnostic( + TrackLayoutPackageV2ValidationCode.EmptySections, + "sections", + "At least one section is required.")); + return false; + } + + var sectionIdIndexes = new Dictionary(StringComparer.Ordinal); + for (int i = 0; i < sections.Length; i++) + { + string path = "sections[" + i + "]"; + TrackLayoutSectionV2Dto? section = sections[i]; + if (section == null) + { + AddMissingObject(path, diagnostics); + continue; + } + + ValidateSectionCommon(section, i, path, sectionIdIndexes, diagnostics); + ValidateSectionByKind(section, i, path, diagnostics); + + if (IsFinite(section.Length)) + { + totalLength += section.Length; + if (!IsFinite(totalLength)) + { + totalLengthValid = false; + diagnostics.Add( + new TrackLayoutPackageV2ValidationDiagnostic( + TrackLayoutPackageV2ValidationCode.NonFiniteNumber, + "sections.totalLength", + "Combined section length must be finite.", + totalLength)); + } + } + else + { + totalLengthValid = false; + } + } + + return totalLengthValid; + } + + private static void ValidateSectionCommon( + TrackLayoutSectionV2Dto section, + int sectionIndex, + string path, + Dictionary sectionIdIndexes, + List diagnostics) + { + if (string.IsNullOrWhiteSpace(section.Kind)) + { + diagnostics.Add( + new TrackLayoutPackageV2ValidationDiagnostic( + TrackLayoutPackageV2ValidationCode.MissingRequiredField, + path + ".kind", + TrackLayoutPackageV2DiagnosticMessages.Section( + section, + sectionIndex, + "Section kind is required."))); + } + + if (string.IsNullOrWhiteSpace(section.Id)) + { + diagnostics.Add( + new TrackLayoutPackageV2ValidationDiagnostic( + TrackLayoutPackageV2ValidationCode.MissingSectionId, + path + ".id", + TrackLayoutPackageV2DiagnosticMessages.Section( + section, + sectionIndex, + "Section ID is required and must not be blank."))); + } + else if (sectionIdIndexes.TryGetValue(section.Id, out int previousSectionIndex)) + { + diagnostics.Add( + new TrackLayoutPackageV2ValidationDiagnostic( + TrackLayoutPackageV2ValidationCode.DuplicateSectionId, + path + ".id", + TrackLayoutPackageV2DiagnosticMessages.DuplicateSectionId( + section, + sectionIndex, + previousSectionIndex))); + } + else + { + sectionIdIndexes.Add(section.Id, sectionIndex); + } + + string sectionContext = TrackLayoutPackageV2DiagnosticMessages.SectionContext(section, sectionIndex); + bool lengthFinite = ValidateFinite(section.Length, path + ".length", diagnostics, sectionContext); + if (lengthFinite && section.Length <= 0.0) + { + diagnostics.Add( + new TrackLayoutPackageV2ValidationDiagnostic( + TrackLayoutPackageV2ValidationCode.NonPositiveLength, + path + ".length", + TrackLayoutPackageV2DiagnosticMessages.WithContext( + "Section length must be finite and greater than zero.", + sectionContext), + section.Length, + 0.0)); + } + + ValidateFinite(section.RollRadians, path + ".rollRadians", diagnostics, sectionContext); + } + + private static void ValidateSectionByKind( + TrackLayoutSectionV2Dto section, + int sectionIndex, + string path, + List diagnostics) + { + switch (section.Kind) + { + case TrackLayoutPackageV2Vocabulary.StraightSectionKind: + ValidateStraightSection(section, sectionIndex, path, diagnostics); + return; + + case TrackLayoutPackageV2Vocabulary.ConstantCurvatureSectionKind: + ValidateConstantCurvatureSection(section, sectionIndex, path, diagnostics); + return; + + case TrackLayoutPackageV2Vocabulary.CurvatureTransitionSectionKind: + ValidateCurvatureTransitionSection(section, sectionIndex, path, diagnostics); + return; + + case TrackLayoutPackageV2Vocabulary.SpatialSectionKind: + ValidateSpatialSection(section, sectionIndex, path, diagnostics); + return; + + default: + diagnostics.Add( + new TrackLayoutPackageV2ValidationDiagnostic( + TrackLayoutPackageV2ValidationCode.UnknownSectionKind, + path + ".kind", + TrackLayoutPackageV2DiagnosticMessages.Section( + section, + sectionIndex, + "Section kind must use the TrackLayoutPackageV2 stable vocabulary."))); + return; + } + } + + private static void ValidateStraightSection( + TrackLayoutSectionV2Dto section, + int sectionIndex, + string path, + List diagnostics) + { + string sectionContext = TrackLayoutPackageV2DiagnosticMessages.SectionContext(section, sectionIndex); + ValidateFieldAbsent(section.Radius, path + ".radius", diagnostics, sectionContext); + ValidateFieldAbsent(section.StartCurvature, path + ".startCurvature", diagnostics, sectionContext); + ValidateFieldAbsent(section.EndCurvature, path + ".endCurvature", diagnostics, sectionContext); + ValidateFieldAbsent(section.InterpolationMode, path + ".interpolationMode", diagnostics, sectionContext); + ValidateFieldAbsent(section.Degree, path + ".degree", diagnostics, sectionContext); + ValidateFieldAbsent(section.ControlPoints, path + ".controlPoints", diagnostics, sectionContext); + ValidateFieldAbsent(section.Weights, path + ".weights", diagnostics, sectionContext); + } + + private static void ValidateConstantCurvatureSection( + TrackLayoutSectionV2Dto section, + int sectionIndex, + string path, + List diagnostics) + { + string sectionContext = TrackLayoutPackageV2DiagnosticMessages.SectionContext(section, sectionIndex); + if (!section.Radius.HasValue) + { + AddMissingField( + path + ".radius", + "Constant-curvature section radius is required.", + diagnostics, + sectionContext); + } + else + { + bool finite = ValidateFinite(section.Radius.Value, path + ".radius", diagnostics, sectionContext); + if (finite && section.Radius.Value == 0.0) + { + diagnostics.Add( + new TrackLayoutPackageV2ValidationDiagnostic( + TrackLayoutPackageV2ValidationCode.InvalidRadius, + path + ".radius", + TrackLayoutPackageV2DiagnosticMessages.WithContext( + "Constant-curvature section radius must be non-zero.", + sectionContext), + section.Radius.Value)); + } + + if (finite && IsFinite(section.Length)) + { + double curvature = 1.0 / section.Radius.Value; + double sweep = section.Length * curvature; + if (!IsFinite(curvature) || !IsFinite(sweep)) + { + diagnostics.Add( + new TrackLayoutPackageV2ValidationDiagnostic( + TrackLayoutPackageV2ValidationCode.InvalidRadius, + path + ".radius", + TrackLayoutPackageV2DiagnosticMessages.WithContext( + "Constant-curvature section radius must produce finite curvature and sweep values.", + sectionContext), + section.Radius.Value)); + } + } + } + + ValidateFieldAbsent(section.StartCurvature, path + ".startCurvature", diagnostics, sectionContext); + ValidateFieldAbsent(section.EndCurvature, path + ".endCurvature", diagnostics, sectionContext); + ValidateFieldAbsent(section.InterpolationMode, path + ".interpolationMode", diagnostics, sectionContext); + ValidateFieldAbsent(section.Degree, path + ".degree", diagnostics, sectionContext); + ValidateFieldAbsent(section.ControlPoints, path + ".controlPoints", diagnostics, sectionContext); + ValidateFieldAbsent(section.Weights, path + ".weights", diagnostics, sectionContext); + } + + private static void ValidateCurvatureTransitionSection( + TrackLayoutSectionV2Dto section, + int sectionIndex, + string path, + List diagnostics) + { + string sectionContext = TrackLayoutPackageV2DiagnosticMessages.SectionContext(section, sectionIndex); + ValidateFieldAbsent(section.Radius, path + ".radius", diagnostics, sectionContext); + ValidateFieldAbsent(section.Degree, path + ".degree", diagnostics, sectionContext); + ValidateFieldAbsent(section.ControlPoints, path + ".controlPoints", diagnostics, sectionContext); + ValidateFieldAbsent(section.Weights, path + ".weights", diagnostics, sectionContext); + + bool startValid = ValidateRequiredFinite( + section.StartCurvature, + path + ".startCurvature", + "Curvature-transition start curvature is required.", + diagnostics, + sectionContext); + bool endValid = ValidateRequiredFinite( + section.EndCurvature, + path + ".endCurvature", + "Curvature-transition end curvature is required.", + diagnostics, + sectionContext); + + if (string.IsNullOrWhiteSpace(section.InterpolationMode)) + { + AddMissingField( + path + ".interpolationMode", + "Curvature-transition interpolation mode is required.", + diagnostics, + sectionContext); + } + else if (!TrackLayoutPackageV2Vocabulary.IsKnownCurvatureTransitionInterpolation( + section.InterpolationMode)) + { + diagnostics.Add( + new TrackLayoutPackageV2ValidationDiagnostic( + TrackLayoutPackageV2ValidationCode.InvalidCurvatureInterpolation, + path + ".interpolationMode", + TrackLayoutPackageV2DiagnosticMessages.WithContext( + "Curvature-transition interpolation mode must be linear.", + sectionContext))); + } + + if (startValid && endValid && IsFinite(section.Length)) + { + double curvatureDelta = section.EndCurvature!.Value - section.StartCurvature!.Value; + double headingSweep = section.Length * + (section.StartCurvature.Value + (0.5 * curvatureDelta)); + if (!IsFinite(curvatureDelta)) + { + diagnostics.Add( + new TrackLayoutPackageV2ValidationDiagnostic( + TrackLayoutPackageV2ValidationCode.NonFiniteNumber, + path + ".curvatureDelta", + TrackLayoutPackageV2DiagnosticMessages.WithContext( + "Curvature range must be finite.", + sectionContext), + curvatureDelta)); + } + + if (!IsFinite(headingSweep)) + { + diagnostics.Add( + new TrackLayoutPackageV2ValidationDiagnostic( + TrackLayoutPackageV2ValidationCode.NonFiniteNumber, + path + ".headingSweep", + TrackLayoutPackageV2DiagnosticMessages.WithContext( + "Transition curvature must produce a finite heading sweep.", + sectionContext), + headingSweep)); + } + } + } + + private static void ValidateSpatialSection( + TrackLayoutSectionV2Dto section, + int sectionIndex, + string path, + List diagnostics) + { + string sectionContext = TrackLayoutPackageV2DiagnosticMessages.SectionContext(section, sectionIndex); + ValidateFieldAbsent(section.Radius, path + ".radius", diagnostics, sectionContext); + ValidateFieldAbsent(section.StartCurvature, path + ".startCurvature", diagnostics, sectionContext); + ValidateFieldAbsent(section.EndCurvature, path + ".endCurvature", diagnostics, sectionContext); + ValidateFieldAbsent(section.InterpolationMode, path + ".interpolationMode", diagnostics, sectionContext); + + if (!section.Degree.HasValue) + { + AddMissingField(path + ".degree", "Spatial section degree is required.", diagnostics, sectionContext); + } + else if (section.Degree.Value < 1) + { + diagnostics.Add( + new TrackLayoutPackageV2ValidationDiagnostic( + TrackLayoutPackageV2ValidationCode.InvalidSpatialDegree, + path + ".degree", + TrackLayoutPackageV2DiagnosticMessages.WithContext( + "Spatial section degree must be at least 1.", + sectionContext), + section.Degree.Value, + 1.0)); + } + + TrackLayoutVector3dV2Dto[]? controlPoints = section.ControlPoints; + if (controlPoints == null) + { + AddMissingField( + path + ".controlPoints", + "Spatial section control points are required.", + diagnostics, + sectionContext); + } + else + { + ValidateSpatialControlPoints( + controlPoints, + section.Degree, + path + ".controlPoints", + diagnostics, + sectionContext); + } + + double[]? weights = section.Weights; + if (weights == null) + { + AddMissingField(path + ".weights", "Spatial section weights are required.", diagnostics, sectionContext); + } + else + { + ValidateSpatialWeights(weights, controlPoints, path + ".weights", diagnostics, sectionContext); + } + + if (controlPoints != null) + { + ValidateSpatialStartContract(controlPoints, path + ".controlPoints", diagnostics, sectionContext); + } + } + + private static void ValidateSpatialControlPoints( + TrackLayoutVector3dV2Dto[] controlPoints, + int? degree, + string path, + List diagnostics, + string sectionContext) + { + if (controlPoints.Length == 0) + { + diagnostics.Add( + new TrackLayoutPackageV2ValidationDiagnostic( + TrackLayoutPackageV2ValidationCode.InvalidSpatialControlPoints, + path, + TrackLayoutPackageV2DiagnosticMessages.WithContext( + "Spatial section control points cannot be empty.", + sectionContext))); + } + + if (degree.HasValue && degree.Value >= 1 && controlPoints.Length <= degree.Value) + { + diagnostics.Add( + new TrackLayoutPackageV2ValidationDiagnostic( + TrackLayoutPackageV2ValidationCode.InvalidSpatialControlPoints, + path, + TrackLayoutPackageV2DiagnosticMessages.WithContext( + "Spatial section control point count must be at least degree + 1.", + sectionContext), + controlPoints.Length, + degree.Value + 1.0)); + } + + for (int i = 0; i < controlPoints.Length; i++) + { + ValidateFiniteVector(controlPoints[i], path + "[" + i + "]", diagnostics, sectionContext); + } + } + + private static void ValidateSpatialWeights( + double[] weights, + TrackLayoutVector3dV2Dto[]? controlPoints, + string path, + List diagnostics, + string sectionContext) + { + if (controlPoints != null && weights.Length != controlPoints.Length) + { + diagnostics.Add( + new TrackLayoutPackageV2ValidationDiagnostic( + TrackLayoutPackageV2ValidationCode.InvalidSpatialWeights, + path, + TrackLayoutPackageV2DiagnosticMessages.WithContext( + "Spatial section weight count must match control point count.", + sectionContext), + weights.Length, + controlPoints.Length)); + } + + for (int i = 0; i < weights.Length; i++) + { + bool finite = ValidateFinite(weights[i], path + "[" + i + "]", diagnostics, sectionContext); + if (finite && weights[i] <= 0.0) + { + diagnostics.Add( + new TrackLayoutPackageV2ValidationDiagnostic( + TrackLayoutPackageV2ValidationCode.InvalidSpatialWeights, + path + "[" + i + "]", + TrackLayoutPackageV2DiagnosticMessages.WithContext( + "Spatial section weights must be finite and greater than zero.", + sectionContext), + weights[i], + 0.0)); + } + } + } + + private static void ValidateSpatialStartContract( + TrackLayoutVector3dV2Dto[] controlPoints, + string path, + List diagnostics, + string sectionContext) + { + if (controlPoints.Length < 2 || controlPoints[0] == null || controlPoints[1] == null) + { + return; + } + + TrackLayoutVector3dV2Dto start = controlPoints[0]; + TrackLayoutVector3dV2Dto next = controlPoints[1]; + if (!IsFiniteVector(start) || !IsFiniteVector(next)) + { + return; + } + + if (start.X != 0.0 || start.Y != 0.0 || start.Z != 0.0) + { + diagnostics.Add( + new TrackLayoutPackageV2ValidationDiagnostic( + TrackLayoutPackageV2ValidationCode.InvalidSpatialStartContract, + path + "[0]", + TrackLayoutPackageV2DiagnosticMessages.WithContext( + "Spatial section local start point must be the origin.", + sectionContext))); + } + + double dx = next.X - start.X; + double dy = next.Y - start.Y; + double dz = next.Z - start.Z; + double directionLength = System.Math.Sqrt((dx * dx) + (dy * dy) + (dz * dz)); + if (directionLength <= MinimumSpatialStartTangentMagnitude) + { + diagnostics.Add( + new TrackLayoutPackageV2ValidationDiagnostic( + TrackLayoutPackageV2ValidationCode.InvalidSpatialStartContract, + path + "[1]", + TrackLayoutPackageV2DiagnosticMessages.WithContext( + "Spatial section local start tangent must have non-zero magnitude and point along positive X.", + sectionContext), + directionLength, + MinimumSpatialStartTangentMagnitude, + MinimumSpatialStartTangentMagnitude)); + return; + } + + double tangentX = dx / directionLength; + double tangentY = dy / directionLength; + double tangentZ = dz / directionLength; + if (tangentX < 1.0 - SpatialStartTangentAlignmentTolerance || + System.Math.Abs(tangentY) > SpatialStartTangentAlignmentTolerance || + System.Math.Abs(tangentZ) > SpatialStartTangentAlignmentTolerance) + { + diagnostics.Add( + new TrackLayoutPackageV2ValidationDiagnostic( + TrackLayoutPackageV2ValidationCode.InvalidSpatialStartContract, + path + "[1]", + TrackLayoutPackageV2DiagnosticMessages.WithContext( + "Spatial section local start tangent must point along positive X.", + sectionContext), + tangentX, + 1.0, + SpatialStartTangentAlignmentTolerance)); + } + } + + private static void ValidateBanking( + TrackBankingV2Dto? banking, + double totalLength, + bool totalLengthValid, + List diagnostics) + { + if (banking == null) + { + return; + } + + TrackBankingKeyV2Dto[]? keys = banking.Keys; + if (keys == null) + { + AddMissingField("banking.keys", "Banking keys collection is required.", diagnostics); + return; + } + + if (keys.Length < 2) + { + diagnostics.Add( + new TrackLayoutPackageV2ValidationDiagnostic( + TrackLayoutPackageV2ValidationCode.InvalidBankingKeyCount, + "banking.keys", + "Authored banking requires at least two keys.", + keys.Length, + 2.0)); + } + + bool keyDistancesValid = true; + double previousDistance = double.NegativeInfinity; + int previousDistanceIndex = -1; + for (int i = 0; i < keys.Length; i++) + { + string path = "banking.keys[" + i + "]"; + TrackBankingKeyV2Dto? key = keys[i]; + if (key == null) + { + AddMissingObject( + path, + diagnostics, + TrackLayoutPackageV2DiagnosticMessages.BankingKeyContext(i)); + keyDistancesValid = false; + continue; + } + + bool distanceFinite = ValidateFinite( + key.Distance, + path + ".distance", + diagnostics, + TrackLayoutPackageV2DiagnosticMessages.BankingKeyContext(i)); + keyDistancesValid &= distanceFinite; + ValidateFinite( + key.RollRadians, + path + ".rollRadians", + diagnostics, + TrackLayoutPackageV2DiagnosticMessages.BankingKeyContext(i)); + + if (string.IsNullOrWhiteSpace(key.InterpolationToNext)) + { + AddMissingField( + path + ".interpolationToNext", + "Banking interpolationToNext is required.", + diagnostics, + TrackLayoutPackageV2DiagnosticMessages.BankingKeyContext(i)); + } + else if (!TrackLayoutPackageV2Vocabulary.IsKnownBankingInterpolation(key.InterpolationToNext)) + { + diagnostics.Add( + new TrackLayoutPackageV2ValidationDiagnostic( + TrackLayoutPackageV2ValidationCode.InvalidBankingInterpolation, + path + ".interpolationToNext", + TrackLayoutPackageV2DiagnosticMessages.BankingKey( + i, + "Banking interpolationToNext must use the TrackLayoutPackageV2 stable vocabulary."))); + } + + if (distanceFinite) + { + if (key.Distance <= previousDistance) + { + diagnostics.Add( + new TrackLayoutPackageV2ValidationDiagnostic( + TrackLayoutPackageV2ValidationCode.InvalidBankingKeyOrder, + path + ".distance", + TrackLayoutPackageV2DiagnosticMessages.BankingKeyOrder( + i, + previousDistanceIndex, + previousDistance), + key.Distance, + previousDistance)); + } + + previousDistance = key.Distance; + previousDistanceIndex = i; + } + } + + if (!totalLengthValid || !keyDistancesValid || keys.Length < 2) + { + return; + } + + TrackBankingKeyV2Dto first = keys[0]; + TrackBankingKeyV2Dto last = keys[keys.Length - 1]; + if (first == null || last == null) + { + return; + } + + if (first.Distance != 0.0) + { + diagnostics.Add( + new TrackLayoutPackageV2ValidationDiagnostic( + TrackLayoutPackageV2ValidationCode.InvalidBankingDomain, + "banking.keys[0].distance", + TrackLayoutPackageV2DiagnosticMessages.BankingKey( + 0, + "Authored banking must start exactly at distance 0."), + first.Distance, + 0.0)); + } + + if (last.Distance != totalLength) + { + diagnostics.Add( + new TrackLayoutPackageV2ValidationDiagnostic( + TrackLayoutPackageV2ValidationCode.InvalidBankingDomain, + "banking.keys[" + (keys.Length - 1) + "].distance", + TrackLayoutPackageV2DiagnosticMessages.BankingFinalDomain( + keys.Length - 1, + totalLength), + last.Distance, + totalLength)); + } + } + + private static void ValidateHeartline( + TrackHeartlineV2Dto? heartline, + List diagnostics) + { + if (heartline == null) + { + return; + } + + if (!TrackLayoutPackageV2Vocabulary.IsKnownHeartlineKind(heartline.Kind)) + { + diagnostics.Add( + new TrackLayoutPackageV2ValidationDiagnostic( + TrackLayoutPackageV2ValidationCode.InvalidHeartlineKind, + "heartline.kind", + "Heartline kind must be constantOffset.")); + } + + if (!TrackLayoutPackageV2Vocabulary.IsKnownHeartlineDistanceDomain(heartline.DistanceDomain)) + { + diagnostics.Add( + new TrackLayoutPackageV2ValidationDiagnostic( + TrackLayoutPackageV2ValidationCode.InvalidHeartlineDistanceDomain, + "heartline.distanceDomain", + "Heartline distanceDomain must be centerlineStation.")); + } + + if (!TrackLayoutPackageV2Vocabulary.IsKnownHeartlineAxisSource(heartline.AxisSource)) + { + diagnostics.Add( + new TrackLayoutPackageV2ValidationDiagnostic( + TrackLayoutPackageV2ValidationCode.InvalidHeartlineAxisSource, + "heartline.axisSource", + "Heartline axisSource must be sampledFrame.")); + } + + ValidateFinite(heartline.NormalOffset, "heartline.normalOffset", diagnostics); + ValidateFinite(heartline.LateralOffset, "heartline.lateralOffset", diagnostics); + } + + private static bool ValidateRequiredFinite( + double? value, + string path, + string missingMessage, + List diagnostics, + string? context = null) + { + if (!value.HasValue) + { + AddMissingField(path, missingMessage, diagnostics, context); + return false; + } + + return ValidateFinite(value.Value, path, diagnostics, context); + } + + private static void ValidateFieldAbsent( + object? value, + string path, + List diagnostics, + string? context = null) + { + if (value == null) + { + return; + } + + diagnostics.Add( + new TrackLayoutPackageV2ValidationDiagnostic( + TrackLayoutPackageV2ValidationCode.UnexpectedSectionField, + path, + TrackLayoutPackageV2DiagnosticMessages.WithContext( + "Section field is not valid for the selected section kind.", + context))); + } + + private static void AddMissingField( + string path, + string message, + List diagnostics, + string? context = null) + { + diagnostics.Add( + new TrackLayoutPackageV2ValidationDiagnostic( + TrackLayoutPackageV2ValidationCode.MissingRequiredField, + path, + TrackLayoutPackageV2DiagnosticMessages.WithContext(message, context))); + } + + private static void AddMissingObject( + string path, + List diagnostics, + string? context = null) + { + diagnostics.Add( + new TrackLayoutPackageV2ValidationDiagnostic( + TrackLayoutPackageV2ValidationCode.MissingObject, + path, + TrackLayoutPackageV2DiagnosticMessages.WithContext( + "Required object is missing.", + context))); + } + + private static bool ValidateBasisVector( + TrackLayoutVector3dV2Dto? vector, + string path, + List diagnostics) + { + if (!ValidateFiniteVector(vector, path, diagnostics)) + { + return false; + } + + double length = Length(vector!); + if (length <= MinimumAxisMagnitude) + { + diagnostics.Add( + new TrackLayoutPackageV2ValidationDiagnostic( + TrackLayoutPackageV2ValidationCode.InvalidStartPoseBasis, + path, + "Start pose basis axes must not have near-zero magnitude.", + length, + MinimumAxisMagnitude, + MinimumAxisMagnitude)); + return false; + } + + return true; + } + + private static void ValidateUnitLength( + TrackLayoutVector3dV2Dto vector, + string path, + List diagnostics) + { + double length = Length(vector); + double delta = System.Math.Abs(length - 1.0); + if (delta > UnitLengthTolerance) + { + diagnostics.Add( + new TrackLayoutPackageV2ValidationDiagnostic( + TrackLayoutPackageV2ValidationCode.InvalidStartPoseBasis, + path, + "Start pose basis axes must be unit length; imports do not normalize inputs.", + length, + 1.0, + UnitLengthTolerance)); + } + } + + private static void ValidateOrthogonality( + TrackLayoutVector3dV2Dto first, + TrackLayoutVector3dV2Dto second, + string path, + List diagnostics) + { + double dot = Dot(first, second); + if (System.Math.Abs(dot) > OrthogonalityTolerance) + { + diagnostics.Add( + new TrackLayoutPackageV2ValidationDiagnostic( + TrackLayoutPackageV2ValidationCode.InvalidStartPoseBasis, + path, + "Start pose basis axes must be mutually orthogonal.", + dot, + 0.0, + OrthogonalityTolerance)); + } + } + + private static bool ValidateFiniteVector( + TrackLayoutVector3dV2Dto? vector, + string path, + List diagnostics, + string? context = null) + { + if (vector == null) + { + AddMissingObject(path, diagnostics, context); + return false; + } + + bool finite = true; + finite &= ValidateFinite(vector.X, path + ".x", diagnostics, context); + finite &= ValidateFinite(vector.Y, path + ".y", diagnostics, context); + finite &= ValidateFinite(vector.Z, path + ".z", diagnostics, context); + return finite; + } + + private static bool ValidateFinite( + double value, + string path, + List diagnostics, + string? context = null) + { + if (IsFinite(value)) + { + return true; + } + + diagnostics.Add( + new TrackLayoutPackageV2ValidationDiagnostic( + TrackLayoutPackageV2ValidationCode.NonFiniteNumber, + path, + TrackLayoutPackageV2DiagnosticMessages.WithContext( + "Numeric value must be finite.", + context), + value)); + return false; + } + + private static bool IsFinite(double value) + { + return !double.IsNaN(value) && !double.IsInfinity(value); + } + + private static bool IsFiniteVector(TrackLayoutVector3dV2Dto? vector) + { + return vector != null && + IsFinite(vector.X) && + IsFinite(vector.Y) && + IsFinite(vector.Z); + } + + private static double Length(TrackLayoutVector3dV2Dto vector) + { + return System.Math.Sqrt((vector.X * vector.X) + (vector.Y * vector.Y) + (vector.Z * vector.Z)); + } + + private static double Dot(TrackLayoutVector3dV2Dto first, TrackLayoutVector3dV2Dto second) + { + return (first.X * second.X) + (first.Y * second.Y) + (first.Z * second.Z); + } + + private static TrackLayoutVector3dV2Dto Cross( + TrackLayoutVector3dV2Dto first, + TrackLayoutVector3dV2Dto second) + { + return new TrackLayoutVector3dV2Dto + { + X = (first.Y * second.Z) - (first.Z * second.Y), + Y = (first.Z * second.X) - (first.X * second.Z), + Z = (first.X * second.Y) - (first.Y * second.X) + }; + } + } +} diff --git a/Quantum.IO/TrackLayout/V2/TrackLayoutPackageV2Vocabulary.cs b/Quantum.IO/TrackLayout/V2/TrackLayoutPackageV2Vocabulary.cs new file mode 100644 index 0000000..7749109 --- /dev/null +++ b/Quantum.IO/TrackLayout/V2/TrackLayoutPackageV2Vocabulary.cs @@ -0,0 +1,67 @@ +using System; + +namespace Quantum.IO.TrackLayout.V2 +{ + public static class TrackLayoutPackageV2Vocabulary + { + public const string StraightSectionKind = "straight"; + public const string ConstantCurvatureSectionKind = "constantCurvature"; + public const string CurvatureTransitionSectionKind = "curvatureTransition"; + public const string SpatialSectionKind = "spatial"; + + public const string CurvatureInterpolationLinear = "linear"; + + public const string BankingInterpolationConstant = "constant"; + public const string BankingInterpolationLinear = "linear"; + public const string BankingInterpolationSmoothStep = "smoothStep"; + public const string BankingInterpolationQuadratic = "quadratic"; + public const string BankingInterpolationCubic = "cubic"; + public const string BankingInterpolationQuartic = "quartic"; + public const string BankingInterpolationQuintic = "quintic"; + public const string BankingInterpolationSinusoidal = "sinusoidal"; + + public const string HeartlineKindConstantOffset = "constantOffset"; + public const string HeartlineDistanceDomainCenterlineStation = "centerlineStation"; + public const string HeartlineAxisSourceSampledFrame = "sampledFrame"; + + public static bool IsKnownSectionKind(string? value) + { + return string.Equals(value, StraightSectionKind, StringComparison.Ordinal) || + string.Equals(value, ConstantCurvatureSectionKind, StringComparison.Ordinal) || + string.Equals(value, CurvatureTransitionSectionKind, StringComparison.Ordinal) || + string.Equals(value, SpatialSectionKind, StringComparison.Ordinal); + } + + public static bool IsKnownCurvatureTransitionInterpolation(string? value) + { + return string.Equals(value, CurvatureInterpolationLinear, StringComparison.Ordinal); + } + + public static bool IsKnownBankingInterpolation(string? value) + { + return string.Equals(value, BankingInterpolationConstant, StringComparison.Ordinal) || + string.Equals(value, BankingInterpolationLinear, StringComparison.Ordinal) || + string.Equals(value, BankingInterpolationSmoothStep, StringComparison.Ordinal) || + string.Equals(value, BankingInterpolationQuadratic, StringComparison.Ordinal) || + string.Equals(value, BankingInterpolationCubic, StringComparison.Ordinal) || + string.Equals(value, BankingInterpolationQuartic, StringComparison.Ordinal) || + string.Equals(value, BankingInterpolationQuintic, StringComparison.Ordinal) || + string.Equals(value, BankingInterpolationSinusoidal, StringComparison.Ordinal); + } + + public static bool IsKnownHeartlineKind(string? value) + { + return string.Equals(value, HeartlineKindConstantOffset, StringComparison.Ordinal); + } + + public static bool IsKnownHeartlineDistanceDomain(string? value) + { + return string.Equals(value, HeartlineDistanceDomainCenterlineStation, StringComparison.Ordinal); + } + + public static bool IsKnownHeartlineAxisSource(string? value) + { + return string.Equals(value, HeartlineAxisSourceSampledFrame, StringComparison.Ordinal); + } + } +} diff --git a/Quantum.Tests/IO/TrackLayoutPackageV2Tests.cs b/Quantum.Tests/IO/TrackLayoutPackageV2Tests.cs new file mode 100644 index 0000000..55e26e1 --- /dev/null +++ b/Quantum.Tests/IO/TrackLayoutPackageV2Tests.cs @@ -0,0 +1,253 @@ +using System; +using System.IO; +using System.Text.Json; +using System.Text.Json.Nodes; +using Json.Schema; +using Quantum.IO.TrackLayout.V2; + +namespace Quantum.Tests; + +public sealed class TrackLayoutPackageV2Tests +{ + private static readonly Lazy TrackLayoutPackageSchema = new(CreateTrackLayoutPackageSchema); + + [Fact] + public void ContractConstantsAndVocabulary_AreStable() + { + var dto = new TrackLayoutPackageV2Dto(); + + Assert.Equal("quantum.track_layout_package", TrackLayoutPackageV2Dto.ContractName); + Assert.Equal(2, TrackLayoutPackageV2Dto.ContractVersion); + Assert.Equal(TrackLayoutPackageV2Dto.ContractName, dto.Contract); + Assert.Equal(TrackLayoutPackageV2Dto.ContractVersion, dto.Version); + + Assert.Equal("straight", TrackLayoutPackageV2Vocabulary.StraightSectionKind); + Assert.Equal("constantCurvature", TrackLayoutPackageV2Vocabulary.ConstantCurvatureSectionKind); + Assert.Equal("curvatureTransition", TrackLayoutPackageV2Vocabulary.CurvatureTransitionSectionKind); + Assert.Equal("spatial", TrackLayoutPackageV2Vocabulary.SpatialSectionKind); + Assert.Equal("linear", TrackLayoutPackageV2Vocabulary.CurvatureInterpolationLinear); + Assert.Equal("constantOffset", TrackLayoutPackageV2Vocabulary.HeartlineKindConstantOffset); + Assert.Equal( + "centerlineStation", + TrackLayoutPackageV2Vocabulary.HeartlineDistanceDomainCenterlineStation); + Assert.Equal("sampledFrame", TrackLayoutPackageV2Vocabulary.HeartlineAxisSourceSampledFrame); + + Assert.True(TrackLayoutPackageV2Vocabulary.IsKnownSectionKind("spatial")); + Assert.True(TrackLayoutPackageV2Vocabulary.IsKnownCurvatureTransitionInterpolation("linear")); + Assert.False(TrackLayoutPackageV2Vocabulary.IsKnownCurvatureTransitionInterpolation("smoothStep")); + Assert.True(TrackLayoutPackageV2Vocabulary.IsKnownBankingInterpolation("sinusoidal")); + Assert.True(TrackLayoutPackageV2Vocabulary.IsKnownHeartlineKind("constantOffset")); + Assert.True(TrackLayoutPackageV2Vocabulary.IsKnownHeartlineDistanceDomain("centerlineStation")); + Assert.True(TrackLayoutPackageV2Vocabulary.IsKnownHeartlineAxisSource("sampledFrame")); + } + + [Fact] + public void DefaultDtoShape_IsStable() + { + var dto = new TrackLayoutPackageV2Dto(); + + Assert.Equal("meters", dto.Metadata.Units); + Assert.Null(dto.Metadata.SourceName); + Assert.Null(dto.Metadata.LayoutId); + Assert.Equal(1.0, dto.StartPose.Tangent.X); + Assert.Equal(1.0, dto.StartPose.Normal.Y); + Assert.Equal(1.0, dto.StartPose.Binormal.Z); + Assert.Empty(dto.Sections); + Assert.Null(dto.Banking); + Assert.Null(dto.Heartline); + + string json = TrackLayoutPackageV2Json.Serialize(dto, indented: true); + Assert.Contains("\"layoutId\": null", json); + Assert.Contains("\"banking\": null", json); + Assert.Contains("\"heartline\": null", json); + } + + [Fact] + public void Serialize_RepresentativePackage_IsDeterministicCamelCaseJson() + { + TrackLayoutPackageV2Dto dto = CreateConstantHeartlineDto(); + + string first = NormalizeLineEndings(TrackLayoutPackageV2Json.Serialize(dto, indented: true)).TrimEnd(); + string second = NormalizeLineEndings(TrackLayoutPackageV2Json.Serialize(dto, indented: true)).TrimEnd(); + string roundtrip = NormalizeLineEndings( + TrackLayoutPackageV2Json.Serialize( + TrackLayoutPackageV2Json.Deserialize(first), + indented: true)).TrimEnd(); + + Assert.Equal(first, second); + Assert.Equal(first, roundtrip); + Assert.Contains("\"startPose\":", first); + Assert.Contains("\"layoutId\":", first); + Assert.Contains("\"constantOffset\"", first); + Assert.Contains("\"normalOffset\":", first); + Assert.DoesNotContain("\"StartPose\":", first); + Assert.DoesNotContain("\"NormalOffset\":", first); + } + + [Fact] + public void SchemaValidation_MinimalV2Json_IsValid() + { + string json = TrackLayoutPackageV2Json.Serialize(CreateMinimalDto()); + + Assert.True(IsValidAgainstSchema(json)); + } + + [Fact] + public void SchemaValidation_ConstantHeartlineV2Json_IsValid() + { + string json = TrackLayoutPackageV2Json.Serialize(CreateConstantHeartlineDto()); + + Assert.True(IsValidAgainstSchema(json)); + } + + [Fact] + public void SchemaValidation_RejectsWrongVersion() + { + JsonObject json = ParseJsonObject(TrackLayoutPackageV2Json.Serialize(CreateMinimalDto())); + json["version"] = 1; + + Assert.False(IsValidAgainstSchema(json.ToJsonString())); + } + + [Fact] + public void SchemaValidation_RejectsUnknownRootFields() + { + JsonObject json = ParseJsonObject(TrackLayoutPackageV2Json.Serialize(CreateMinimalDto())); + json["train"] = new JsonObject(); + + Assert.False(IsValidAgainstSchema(json.ToJsonString())); + } + + private static TrackLayoutPackageV2Dto CreateMinimalDto() + { + return new TrackLayoutPackageV2Dto + { + Metadata = new TrackLayoutMetadataV2Dto + { + Units = "meters", + SourceName = "Minimal V2 layout", + LayoutId = null + }, + Sections = new[] + { + new TrackLayoutSectionV2Dto + { + Kind = TrackLayoutPackageV2Vocabulary.StraightSectionKind, + Id = "entry", + Length = 12.0, + RollRadians = 0.0 + } + }, + Banking = null, + Heartline = null + }; + } + + private static TrackLayoutPackageV2Dto CreateConstantHeartlineDto() + { + TrackLayoutPackageV2Dto dto = CreateMinimalDto(); + dto.Metadata.SourceName = "Constant heartline V2 layout"; + dto.Metadata.LayoutId = "layout.m147.constant-heartline"; + dto.Sections = new[] + { + new TrackLayoutSectionV2Dto + { + Kind = TrackLayoutPackageV2Vocabulary.StraightSectionKind, + Id = "entry", + Length = 12.0, + RollRadians = 0.0 + }, + new TrackLayoutSectionV2Dto + { + Kind = TrackLayoutPackageV2Vocabulary.ConstantCurvatureSectionKind, + Id = "turn", + Length = 18.0, + RollRadians = 0.2, + Radius = 30.0 + } + }; + dto.Banking = new TrackBankingV2Dto + { + Keys = new[] + { + new TrackBankingKeyV2Dto + { + Distance = 0.0, + RollRadians = 0.0, + InterpolationToNext = TrackLayoutPackageV2Vocabulary.BankingInterpolationLinear + }, + new TrackBankingKeyV2Dto + { + Distance = 12.0, + RollRadians = 0.2, + InterpolationToNext = TrackLayoutPackageV2Vocabulary.BankingInterpolationConstant + }, + new TrackBankingKeyV2Dto + { + Distance = 30.0, + RollRadians = 0.2, + InterpolationToNext = TrackLayoutPackageV2Vocabulary.BankingInterpolationConstant + } + } + }; + dto.Heartline = new TrackHeartlineV2Dto + { + NormalOffset = 1.1, + LateralOffset = 0.0 + }; + + return dto; + } + + private static bool IsValidAgainstSchema(string instanceJson) + { + using JsonDocument instanceDocument = JsonDocument.Parse(instanceJson); + + EvaluationResults results = TrackLayoutPackageSchema.Value.Evaluate( + instanceDocument.RootElement, + new EvaluationOptions + { + OutputFormat = OutputFormat.List + }); + + return results.IsValid; + } + + private static JsonSchema CreateTrackLayoutPackageSchema() + { + string schemaPath = FindContractFile("track-layout-package-v2.schema.json"); + string schemaJson = File.ReadAllText(schemaPath); + return JsonSchema.FromText(schemaJson); + } + + private static string FindContractFile(string fileName) + { + DirectoryInfo? current = new(AppContext.BaseDirectory); + + while (current != null) + { + string candidate = Path.Combine(current.FullName, "docs", "contracts", fileName); + if (File.Exists(candidate)) + { + return candidate; + } + + current = current.Parent; + } + + throw new FileNotFoundException( + "Contract file '" + fileName + "' was not found from '" + AppContext.BaseDirectory + "'."); + } + + private static JsonObject ParseJsonObject(string json) + { + JsonNode? node = JsonNode.Parse(json); + Assert.NotNull(node); + return Assert.IsType(node); + } + + private static string NormalizeLineEndings(string value) + { + return value.ReplaceLineEndings("\n"); + } +} diff --git a/Quantum.Tests/IO/TrackLayoutPackageV2ValidatorTests.cs b/Quantum.Tests/IO/TrackLayoutPackageV2ValidatorTests.cs new file mode 100644 index 0000000..d5bd45c --- /dev/null +++ b/Quantum.Tests/IO/TrackLayoutPackageV2ValidatorTests.cs @@ -0,0 +1,710 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Quantum.IO.TrackLayout.V2; + +namespace Quantum.Tests; + +public sealed class TrackLayoutPackageV2ValidatorTests +{ + [Fact] + public void DefaultDto_IsInvalidOnlyBecauseSectionsIsEmpty() + { + var dto = new TrackLayoutPackageV2Dto(); + + bool isValid = TrackLayoutPackageV2Validator.TryValidate( + dto, + out IReadOnlyList diagnostics); + + Assert.False(isValid); + TrackLayoutPackageV2ValidationDiagnostic diagnostic = Assert.Single(diagnostics); + Assert.Equal(TrackLayoutPackageV2ValidationCode.EmptySections, diagnostic.Code); + Assert.Equal("sections", diagnostic.Path); + } + + [Fact] + public void MinimalValidDto_ValidatesCleanly() + { + TrackLayoutPackageV2Dto dto = CreateMinimalDto(); + + AssertValid(dto); + } + + [Fact] + public void ConstantHeartlineRepresentativeDto_ValidatesCleanly() + { + TrackLayoutPackageV2Dto dto = CreateConstantHeartlineDto(); + + AssertValid(dto); + } + + [Fact] + public void ValidationCodeValues_AreStable() + { + Assert.Equal(0, (int)TrackLayoutPackageV2ValidationCode.InvalidContract); + Assert.Equal(24, (int)TrackLayoutPackageV2ValidationCode.InvalidBankingDomain); + Assert.Equal(25, (int)TrackLayoutPackageV2ValidationCode.InvalidHeartlineKind); + Assert.Equal(26, (int)TrackLayoutPackageV2ValidationCode.InvalidHeartlineDistanceDomain); + Assert.Equal(27, (int)TrackLayoutPackageV2ValidationCode.InvalidHeartlineAxisSource); + } + + [Fact] + public void Validator_CatchesMissingMetadataAndBlankUnits() + { + TrackLayoutPackageV2Dto missingMetadata = CreateMinimalDto(); + missingMetadata.Metadata = null!; + + TrackLayoutPackageV2ValidationDiagnostic metadataDiagnostic = AssertDiagnostic( + TrackLayoutPackageV2Validator.Validate(missingMetadata), + TrackLayoutPackageV2ValidationCode.MissingMetadata, + "metadata"); + + Assert.Equal("Metadata object is required.", metadataDiagnostic.Message); + + TrackLayoutPackageV2Dto blankUnits = CreateMinimalDto(); + blankUnits.Metadata.Units = " "; + + AssertDiagnostic( + TrackLayoutPackageV2Validator.Validate(blankUnits), + TrackLayoutPackageV2ValidationCode.MissingRequiredField, + "metadata.units"); + } + + [Fact] + public void Validator_CatchesWrongContractAndVersion() + { + TrackLayoutPackageV2Dto dto = CreateMinimalDto(); + dto.Contract = "quantum.track_layout_package.v1"; + dto.Version = 1; + + IReadOnlyList diagnostics = + TrackLayoutPackageV2Validator.Validate(dto); + + Assert.Equal(2, diagnostics.Count); + AssertDiagnostic( + diagnostics, + TrackLayoutPackageV2ValidationCode.InvalidContract, + "contract"); + TrackLayoutPackageV2ValidationDiagnostic version = AssertDiagnostic( + diagnostics, + TrackLayoutPackageV2ValidationCode.InvalidVersion, + "version"); + Assert.Equal(1.0, version.Value); + Assert.Equal(2.0, version.Expected); + } + + [Fact] + public void Validator_CatchesMissingAndEmptySections() + { + TrackLayoutPackageV2Dto missing = CreateMinimalDto(); + missing.Sections = null!; + + AssertDiagnostic( + TrackLayoutPackageV2Validator.Validate(missing), + TrackLayoutPackageV2ValidationCode.MissingSections, + "sections"); + + TrackLayoutPackageV2Dto empty = CreateMinimalDto(); + empty.Sections = Array.Empty(); + + AssertDiagnostic( + TrackLayoutPackageV2Validator.Validate(empty), + TrackLayoutPackageV2ValidationCode.EmptySections, + "sections"); + } + + [Fact] + public void Validator_CatchesNullSectionAndBlankSectionIdentity() + { + TrackLayoutPackageV2Dto dto = CreateMinimalDto(); + dto.Sections = new[] + { + null!, + new TrackLayoutSectionV2Dto + { + Kind = " ", + Id = " ", + Length = 1.0, + RollRadians = 0.0 + } + }; + + IReadOnlyList diagnostics = + TrackLayoutPackageV2Validator.Validate(dto); + + AssertDiagnostic( + diagnostics, + TrackLayoutPackageV2ValidationCode.MissingObject, + "sections[0]"); + AssertDiagnostic( + diagnostics, + TrackLayoutPackageV2ValidationCode.MissingRequiredField, + "sections[1].kind"); + AssertDiagnostic( + diagnostics, + TrackLayoutPackageV2ValidationCode.MissingSectionId, + "sections[1].id"); + } + + [Fact] + public void Validator_CatchesDuplicateSectionIdsUsingOrdinalComparison() + { + TrackLayoutPackageV2Dto duplicate = CreateMinimalDto(); + duplicate.Sections = new[] + { + CreateStraightSection("entry", 2.0), + CreateStraightSection("entry", 3.0) + }; + + AssertDiagnostic( + TrackLayoutPackageV2Validator.Validate(duplicate), + TrackLayoutPackageV2ValidationCode.DuplicateSectionId, + "sections[1].id"); + + TrackLayoutPackageV2Dto ordinalDistinct = CreateMinimalDto(); + ordinalDistinct.Sections = new[] + { + CreateStraightSection("entry", 2.0), + CreateStraightSection("ENTRY", 3.0) + }; + + AssertValid(ordinalDistinct); + } + + [Fact] + public void Validator_CatchesInvalidStartPose() + { + AssertDiagnostic( + ValidateStartPose(p => p.Position.X = double.NaN), + TrackLayoutPackageV2ValidationCode.NonFiniteNumber, + "startPose.position.x"); + AssertDiagnostic( + ValidateStartPose(p => p.Tangent = new TrackLayoutVector3dV2Dto()), + TrackLayoutPackageV2ValidationCode.InvalidStartPoseBasis, + "startPose.tangent"); + AssertDiagnostic( + ValidateStartPose(p => p.Tangent = new TrackLayoutVector3dV2Dto { X = 2.0 }), + TrackLayoutPackageV2ValidationCode.InvalidStartPoseBasis, + "startPose.tangent"); + AssertDiagnostic( + ValidateStartPose( + p => + { + p.Tangent = new TrackLayoutVector3dV2Dto { X = 1.0 }; + p.Normal = new TrackLayoutVector3dV2Dto { X = 1.0 }; + p.Binormal = new TrackLayoutVector3dV2Dto { Z = 1.0 }; + }), + TrackLayoutPackageV2ValidationCode.InvalidStartPoseBasis, + "startPose.tangentNormalDot"); + AssertDiagnostic( + ValidateStartPose(p => p.Binormal = new TrackLayoutVector3dV2Dto { Z = -1.0 }), + TrackLayoutPackageV2ValidationCode.InvalidStartPoseBasis, + "startPose.handedness"); + } + + [Fact] + public void Validator_CatchesInvalidSectionCommonFields() + { + TrackLayoutPackageV2Dto dto = CreateMinimalDto(); + dto.Sections = new[] + { + CreateStraightSection("nonfinite-length", double.NaN), + CreateStraightSection("zero-length", 0.0), + new TrackLayoutSectionV2Dto + { + Kind = TrackLayoutPackageV2Vocabulary.StraightSectionKind, + Id = "nonfinite-roll", + Length = 1.0, + RollRadians = double.PositiveInfinity + } + }; + + IReadOnlyList diagnostics = + TrackLayoutPackageV2Validator.Validate(dto); + + AssertDiagnostic( + diagnostics, + TrackLayoutPackageV2ValidationCode.NonFiniteNumber, + "sections[0].length"); + AssertDiagnostic( + diagnostics, + TrackLayoutPackageV2ValidationCode.NonPositiveLength, + "sections[1].length"); + AssertDiagnostic( + diagnostics, + TrackLayoutPackageV2ValidationCode.NonFiniteNumber, + "sections[2].rollRadians"); + } + + [Fact] + public void Validator_CatchesInvalidPerKindSectionFields() + { + TrackLayoutPackageV2Dto dto = CreateRepresentativeDto(); + dto.Banking = null; + dto.Sections[0].Radius = 10.0; + dto.Sections[1].Radius = 0.0; + dto.Sections[2].StartCurvature = double.NaN; + dto.Sections[2].EndCurvature = null; + dto.Sections[2].InterpolationMode = "smoothStep"; + dto.Sections = dto.Sections.Concat(new[] + { + new TrackLayoutSectionV2Dto + { + Kind = "legacy", + Id = "legacy", + Length = 1.0, + RollRadians = 0.0 + }, + new TrackLayoutSectionV2Dto + { + Kind = TrackLayoutPackageV2Vocabulary.ConstantCurvatureSectionKind, + Id = "missing-radius", + Length = 1.0, + RollRadians = 0.0 + } + }).ToArray(); + + IReadOnlyList diagnostics = + TrackLayoutPackageV2Validator.Validate(dto); + + AssertDiagnostic( + diagnostics, + TrackLayoutPackageV2ValidationCode.UnexpectedSectionField, + "sections[0].radius"); + AssertDiagnostic( + diagnostics, + TrackLayoutPackageV2ValidationCode.InvalidRadius, + "sections[1].radius"); + AssertDiagnostic( + diagnostics, + TrackLayoutPackageV2ValidationCode.NonFiniteNumber, + "sections[2].startCurvature"); + AssertDiagnostic( + diagnostics, + TrackLayoutPackageV2ValidationCode.MissingRequiredField, + "sections[2].endCurvature"); + AssertDiagnostic( + diagnostics, + TrackLayoutPackageV2ValidationCode.InvalidCurvatureInterpolation, + "sections[2].interpolationMode"); + AssertDiagnostic( + diagnostics, + TrackLayoutPackageV2ValidationCode.UnknownSectionKind, + "sections[4].kind"); + AssertDiagnostic( + diagnostics, + TrackLayoutPackageV2ValidationCode.MissingRequiredField, + "sections[5].radius"); + } + + [Fact] + public void Validator_CatchesInvalidSpatialDegreeControlPointsWeightsAndStartContract() + { + TrackLayoutPackageV2Dto dto = CreateMinimalDto(); + dto.Sections = new[] + { + new TrackLayoutSectionV2Dto + { + Kind = TrackLayoutPackageV2Vocabulary.SpatialSectionKind, + Id = "spatial-invalid-shape", + Length = 1.0, + RollRadians = 0.0, + Degree = 0, + ControlPoints = Array.Empty(), + Weights = new[] { 0.0 } + }, + new TrackLayoutSectionV2Dto + { + Kind = TrackLayoutPackageV2Vocabulary.SpatialSectionKind, + Id = "spatial-invalid-start", + Length = 1.0, + RollRadians = 0.0, + Degree = 1, + ControlPoints = new[] + { + new TrackLayoutVector3dV2Dto(), + new TrackLayoutVector3dV2Dto { Y = 1.0 } + }, + Weights = new[] { 1.0, 1.0 } + }, + new TrackLayoutSectionV2Dto + { + Kind = TrackLayoutPackageV2Vocabulary.SpatialSectionKind, + Id = "spatial-nonfinite-control-point", + Length = 1.0, + RollRadians = 0.0, + Degree = 1, + ControlPoints = new[] + { + new TrackLayoutVector3dV2Dto(), + new TrackLayoutVector3dV2Dto { X = 1.0, Y = double.NaN } + }, + Weights = new[] { 1.0, 1.0 } + } + }; + + IReadOnlyList diagnostics = + TrackLayoutPackageV2Validator.Validate(dto); + + AssertDiagnostic( + diagnostics, + TrackLayoutPackageV2ValidationCode.InvalidSpatialDegree, + "sections[0].degree"); + AssertDiagnostic( + diagnostics, + TrackLayoutPackageV2ValidationCode.InvalidSpatialControlPoints, + "sections[0].controlPoints"); + AssertDiagnostic( + diagnostics, + TrackLayoutPackageV2ValidationCode.InvalidSpatialWeights, + "sections[0].weights"); + AssertDiagnostic( + diagnostics, + TrackLayoutPackageV2ValidationCode.InvalidSpatialWeights, + "sections[0].weights[0]"); + AssertDiagnostic( + diagnostics, + TrackLayoutPackageV2ValidationCode.InvalidSpatialStartContract, + "sections[1].controlPoints[1]"); + AssertDiagnostic( + diagnostics, + TrackLayoutPackageV2ValidationCode.NonFiniteNumber, + "sections[2].controlPoints[1].y"); + } + + [Fact] + public void Validator_CatchesInvalidBankingCountOrderDomainAndInterpolation() + { + TrackLayoutPackageV2Dto shortBanking = CreateMinimalDto(); + shortBanking.Banking = new TrackBankingV2Dto + { + Keys = new[] + { + new TrackBankingKeyV2Dto + { + Distance = 0.0, + RollRadians = 0.0, + InterpolationToNext = TrackLayoutPackageV2Vocabulary.BankingInterpolationConstant + } + } + }; + + AssertDiagnostic( + TrackLayoutPackageV2Validator.Validate(shortBanking), + TrackLayoutPackageV2ValidationCode.InvalidBankingKeyCount, + "banking.keys"); + + TrackLayoutPackageV2Dto invalid = CreateRepresentativeDto(); + invalid.Banking!.Keys[0].Distance = 1.0; + invalid.Banking.Keys[1].Distance = 0.5; + invalid.Banking.Keys[2].RollRadians = double.NaN; + invalid.Banking.Keys[2].InterpolationToNext = "legacyCubic"; + invalid.Banking.Keys[3].Distance = 35.0; + + IReadOnlyList diagnostics = + TrackLayoutPackageV2Validator.Validate(invalid); + + AssertDiagnostic( + diagnostics, + TrackLayoutPackageV2ValidationCode.InvalidBankingDomain, + "banking.keys[0].distance"); + AssertDiagnostic( + diagnostics, + TrackLayoutPackageV2ValidationCode.InvalidBankingKeyOrder, + "banking.keys[1].distance"); + AssertDiagnostic( + diagnostics, + TrackLayoutPackageV2ValidationCode.NonFiniteNumber, + "banking.keys[2].rollRadians"); + AssertDiagnostic( + diagnostics, + TrackLayoutPackageV2ValidationCode.InvalidBankingInterpolation, + "banking.keys[2].interpolationToNext"); + AssertDiagnostic( + diagnostics, + TrackLayoutPackageV2ValidationCode.InvalidBankingDomain, + "banking.keys[3].distance"); + + TrackLayoutPackageV2Dto nonfiniteDistance = CreateRepresentativeDto(); + nonfiniteDistance.Banking!.Keys[1].Distance = double.NaN; + + AssertDiagnostic( + TrackLayoutPackageV2Validator.Validate(nonfiniteDistance), + TrackLayoutPackageV2ValidationCode.NonFiniteNumber, + "banking.keys[1].distance"); + } + + [Fact] + public void Validator_AllowsNullBankingAndNullHeartline() + { + TrackLayoutPackageV2Dto dto = CreateMinimalDto(); + dto.Banking = null; + dto.Heartline = null; + + AssertValid(dto); + } + + [Fact] + public void Validator_CatchesInvalidHeartlineVocabularyAndNonFiniteOffsets() + { + TrackLayoutPackageV2Dto dto = CreateMinimalDto(); + dto.Heartline = new TrackHeartlineV2Dto + { + Kind = "dynamic", + DistanceDomain = "worldDistance", + AxisSource = "worldUp", + NormalOffset = double.NaN, + LateralOffset = double.NegativeInfinity + }; + + IReadOnlyList diagnostics = + TrackLayoutPackageV2Validator.Validate(dto); + + AssertDiagnostic( + diagnostics, + TrackLayoutPackageV2ValidationCode.InvalidHeartlineKind, + "heartline.kind"); + AssertDiagnostic( + diagnostics, + TrackLayoutPackageV2ValidationCode.InvalidHeartlineDistanceDomain, + "heartline.distanceDomain"); + AssertDiagnostic( + diagnostics, + TrackLayoutPackageV2ValidationCode.InvalidHeartlineAxisSource, + "heartline.axisSource"); + AssertDiagnostic( + diagnostics, + TrackLayoutPackageV2ValidationCode.NonFiniteNumber, + "heartline.normalOffset"); + AssertDiagnostic( + diagnostics, + TrackLayoutPackageV2ValidationCode.NonFiniteNumber, + "heartline.lateralOffset"); + } + + [Fact] + public void Validator_DiagnosticPaths_RemainStableForRepresentativeFailures() + { + TrackLayoutPackageV2Dto dto = CreateConstantHeartlineDto(); + dto.StartPose.Tangent = new TrackLayoutVector3dV2Dto { X = 2.0 }; + dto.Sections[0].Radius = 10.0; + dto.Sections = dto.Sections.Concat(new[] + { + new TrackLayoutSectionV2Dto + { + Kind = TrackLayoutPackageV2Vocabulary.SpatialSectionKind, + Id = "bad-spatial", + Length = 1.0, + RollRadians = 0.0, + Degree = 1, + ControlPoints = new[] + { + new TrackLayoutVector3dV2Dto(), + new TrackLayoutVector3dV2Dto { Y = 1.0 } + }, + Weights = new[] { 1.0, 1.0 } + } + }).ToArray(); + dto.Banking!.Keys[1].Distance = 0.0; + dto.Heartline!.Kind = "dynamic"; + + IReadOnlyList diagnostics = + TrackLayoutPackageV2Validator.Validate(dto); + + AssertDiagnostic( + diagnostics, + TrackLayoutPackageV2ValidationCode.InvalidStartPoseBasis, + "startPose.tangent"); + AssertDiagnostic( + diagnostics, + TrackLayoutPackageV2ValidationCode.UnexpectedSectionField, + "sections[0].radius"); + AssertDiagnostic( + diagnostics, + TrackLayoutPackageV2ValidationCode.InvalidSpatialStartContract, + "sections[4].controlPoints[1]"); + AssertDiagnostic( + diagnostics, + TrackLayoutPackageV2ValidationCode.InvalidBankingKeyOrder, + "banking.keys[1].distance"); + AssertDiagnostic( + diagnostics, + TrackLayoutPackageV2ValidationCode.InvalidHeartlineKind, + "heartline.kind"); + } + + private static IReadOnlyList ValidateStartPose( + Action mutate) + { + TrackLayoutPackageV2Dto dto = CreateMinimalDto(); + mutate(dto.StartPose); + return TrackLayoutPackageV2Validator.Validate(dto); + } + + private static TrackLayoutPackageV2Dto CreateMinimalDto() + { + return new TrackLayoutPackageV2Dto + { + Metadata = new TrackLayoutMetadataV2Dto + { + Units = "meters", + SourceName = "Minimal V2 layout", + LayoutId = null + }, + Sections = new[] + { + CreateStraightSection("entry", 12.0) + }, + Banking = null, + Heartline = null + }; + } + + private static TrackLayoutPackageV2Dto CreateConstantHeartlineDto() + { + TrackLayoutPackageV2Dto dto = CreateRepresentativeDto(); + dto.Metadata.SourceName = "Constant heartline V2 layout"; + dto.Metadata.LayoutId = "layout.m147.constant-heartline"; + dto.Heartline = new TrackHeartlineV2Dto + { + Kind = TrackLayoutPackageV2Vocabulary.HeartlineKindConstantOffset, + DistanceDomain = TrackLayoutPackageV2Vocabulary.HeartlineDistanceDomainCenterlineStation, + AxisSource = TrackLayoutPackageV2Vocabulary.HeartlineAxisSourceSampledFrame, + NormalOffset = 1.1, + LateralOffset = 0.0 + }; + + return dto; + } + + private static TrackLayoutPackageV2Dto CreateRepresentativeDto() + { + return new TrackLayoutPackageV2Dto + { + Metadata = new TrackLayoutMetadataV2Dto + { + Units = "meters", + SourceName = "representative", + LayoutId = "layout.m147.representative" + }, + StartPose = new TrackStartPoseV2Dto + { + Position = new TrackLayoutVector3dV2Dto { X = 1.0, Y = 2.0, Z = 3.0 }, + Tangent = new TrackLayoutVector3dV2Dto { X = 0.0, Y = 1.0, Z = 0.0 }, + Normal = new TrackLayoutVector3dV2Dto { X = 0.0, Y = 0.0, Z = 1.0 }, + Binormal = new TrackLayoutVector3dV2Dto { X = 1.0, Y = 0.0, Z = 0.0 } + }, + Sections = new[] + { + new TrackLayoutSectionV2Dto + { + Kind = TrackLayoutPackageV2Vocabulary.StraightSectionKind, + Id = "entry", + Length = 10.0, + RollRadians = 0.1 + }, + new TrackLayoutSectionV2Dto + { + Kind = TrackLayoutPackageV2Vocabulary.ConstantCurvatureSectionKind, + Id = "turn", + Length = 12.0, + RollRadians = -0.2, + Radius = -30.0 + }, + new TrackLayoutSectionV2Dto + { + Kind = TrackLayoutPackageV2Vocabulary.CurvatureTransitionSectionKind, + Id = "transition", + Length = 8.0, + RollRadians = 0.05, + StartCurvature = 0.02, + EndCurvature = -0.01, + InterpolationMode = TrackLayoutPackageV2Vocabulary.CurvatureInterpolationLinear + }, + new TrackLayoutSectionV2Dto + { + Kind = TrackLayoutPackageV2Vocabulary.SpatialSectionKind, + Id = "spatial", + Length = 6.0, + RollRadians = 0.25, + Degree = 3, + ControlPoints = new[] + { + new TrackLayoutVector3dV2Dto { X = 0.0, Y = 0.0, Z = 0.0 }, + new TrackLayoutVector3dV2Dto { X = 2.0, Y = 0.0, Z = 0.0 }, + new TrackLayoutVector3dV2Dto { X = 4.0, Y = 0.0, Z = 0.0 }, + new TrackLayoutVector3dV2Dto { X = 6.0, Y = 0.0, Z = 0.0 } + }, + Weights = new[] { 1.0, 1.0, 1.0, 1.0 } + } + }, + Banking = new TrackBankingV2Dto + { + Keys = new[] + { + new TrackBankingKeyV2Dto + { + Distance = 0.0, + RollRadians = 0.0, + InterpolationToNext = TrackLayoutPackageV2Vocabulary.BankingInterpolationLinear + }, + new TrackBankingKeyV2Dto + { + Distance = 10.0, + RollRadians = 0.2, + InterpolationToNext = TrackLayoutPackageV2Vocabulary.BankingInterpolationSmoothStep + }, + new TrackBankingKeyV2Dto + { + Distance = 22.0, + RollRadians = -0.35, + InterpolationToNext = TrackLayoutPackageV2Vocabulary.BankingInterpolationConstant + }, + new TrackBankingKeyV2Dto + { + Distance = 36.0, + RollRadians = 0.1, + InterpolationToNext = TrackLayoutPackageV2Vocabulary.BankingInterpolationConstant + } + } + }, + Heartline = null + }; + } + + private static TrackLayoutSectionV2Dto CreateStraightSection(string id, double length) + { + return new TrackLayoutSectionV2Dto + { + Kind = TrackLayoutPackageV2Vocabulary.StraightSectionKind, + Id = id, + Length = length, + RollRadians = 0.0 + }; + } + + private static void AssertValid(TrackLayoutPackageV2Dto dto) + { + IReadOnlyList diagnostics = + TrackLayoutPackageV2Validator.Validate(dto); + + Assert.Empty(diagnostics); + Assert.True( + TrackLayoutPackageV2Validator.TryValidate( + dto, + out IReadOnlyList tryDiagnostics)); + Assert.Empty(tryDiagnostics); + } + + private static TrackLayoutPackageV2ValidationDiagnostic AssertDiagnostic( + IReadOnlyList diagnostics, + TrackLayoutPackageV2ValidationCode code, + string path) + { + TrackLayoutPackageV2ValidationDiagnostic? diagnostic = diagnostics.FirstOrDefault( + d => d.Code == code && d.Path == path); + + Assert.NotNull(diagnostic); + return diagnostic!; + } +} diff --git a/docs/contracts/track-layout-package-v2.schema.json b/docs/contracts/track-layout-package-v2.schema.json new file mode 100644 index 0000000..f9a4551 --- /dev/null +++ b/docs/contracts/track-layout-package-v2.schema.json @@ -0,0 +1,370 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://quantumcoasterworks.dev/schemas/track-layout-package-v2.schema.json", + "title": "TrackLayoutPackageV2", + "description": "Backend-only JSON contract skeleton for authored coaster track layouts with nullable banking and heartline roots.", + "type": "object", + "additionalProperties": false, + "required": [ + "contract", + "version", + "metadata", + "startPose", + "sections", + "banking", + "heartline" + ], + "properties": { + "contract": { + "description": "Stable contract discriminator.", + "type": "string", + "const": "quantum.track_layout_package" + }, + "version": { + "description": "Contract version.", + "type": "integer", + "const": 2 + }, + "metadata": { + "$ref": "#/$defs/metadata" + }, + "startPose": { + "$ref": "#/$defs/startPose" + }, + "sections": { + "description": "Ordered authored geometric section definitions.", + "type": "array", + "minItems": 1, + "items": { + "oneOf": [ + { + "$ref": "#/$defs/straightSection" + }, + { + "$ref": "#/$defs/constantCurvatureSection" + }, + { + "$ref": "#/$defs/curvatureTransitionSection" + }, + { + "$ref": "#/$defs/spatialSection" + } + ] + } + }, + "banking": { + "description": "Optional explicit authored banking. Null means no explicit banking is persisted.", + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/banking" + } + ] + }, + "heartline": { + "description": "Optional authored heartline offset. Null means no authored heartline offset is persisted.", + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/constantHeartline" + } + ] + } + }, + "$defs": { + "metadata": { + "type": "object", + "additionalProperties": false, + "required": [ + "units", + "sourceName" + ], + "properties": { + "units": { + "description": "Distance units used by authored scalar values.", + "type": "string" + }, + "sourceName": { + "description": "Optional source label for the authored layout package.", + "type": [ + "string", + "null" + ] + }, + "layoutId": { + "description": "Optional stable layout identity for companion contracts and manifests.", + "type": [ + "string", + "null" + ] + } + } + }, + "startPose": { + "type": "object", + "additionalProperties": false, + "required": [ + "position", + "tangent", + "normal", + "binormal" + ], + "properties": { + "position": { + "$ref": "#/$defs/vector3d" + }, + "tangent": { + "$ref": "#/$defs/vector3d" + }, + "normal": { + "$ref": "#/$defs/vector3d" + }, + "binormal": { + "$ref": "#/$defs/vector3d" + } + } + }, + "vector3d": { + "type": "object", + "additionalProperties": false, + "required": [ + "x", + "y", + "z" + ], + "properties": { + "x": { + "type": "number" + }, + "y": { + "type": "number" + }, + "z": { + "type": "number" + } + } + }, + "straightSection": { + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "id", + "length", + "rollRadians" + ], + "properties": { + "kind": { + "type": "string", + "const": "straight" + }, + "id": { + "type": "string" + }, + "length": { + "type": "number" + }, + "rollRadians": { + "type": "number" + } + } + }, + "constantCurvatureSection": { + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "id", + "length", + "rollRadians", + "radius" + ], + "properties": { + "kind": { + "type": "string", + "const": "constantCurvature" + }, + "id": { + "type": "string" + }, + "length": { + "type": "number" + }, + "rollRadians": { + "type": "number" + }, + "radius": { + "type": "number" + } + } + }, + "curvatureTransitionSection": { + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "id", + "length", + "rollRadians", + "startCurvature", + "endCurvature", + "interpolationMode" + ], + "properties": { + "kind": { + "type": "string", + "const": "curvatureTransition" + }, + "id": { + "type": "string" + }, + "length": { + "type": "number" + }, + "rollRadians": { + "type": "number" + }, + "startCurvature": { + "type": "number" + }, + "endCurvature": { + "type": "number" + }, + "interpolationMode": { + "type": "string", + "enum": [ + "linear" + ] + } + } + }, + "spatialSection": { + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "id", + "length", + "rollRadians", + "degree", + "controlPoints", + "weights" + ], + "properties": { + "kind": { + "type": "string", + "const": "spatial" + }, + "id": { + "type": "string" + }, + "length": { + "type": "number" + }, + "rollRadians": { + "type": "number" + }, + "degree": { + "type": "integer", + "minimum": 1 + }, + "controlPoints": { + "type": "array", + "minItems": 2, + "items": { + "$ref": "#/$defs/vector3d" + } + }, + "weights": { + "type": "array", + "minItems": 2, + "items": { + "type": "number", + "exclusiveMinimum": 0 + } + } + } + }, + "banking": { + "type": "object", + "additionalProperties": false, + "required": [ + "keys" + ], + "properties": { + "keys": { + "type": "array", + "minItems": 2, + "items": { + "$ref": "#/$defs/bankingKey" + } + } + } + }, + "bankingKey": { + "type": "object", + "additionalProperties": false, + "required": [ + "distance", + "rollRadians", + "interpolationToNext" + ], + "properties": { + "distance": { + "type": "number" + }, + "rollRadians": { + "type": "number" + }, + "interpolationToNext": { + "type": "string", + "enum": [ + "constant", + "linear", + "smoothStep", + "quadratic", + "cubic", + "quartic", + "quintic", + "sinusoidal" + ] + } + } + }, + "constantHeartline": { + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "distanceDomain", + "axisSource", + "normalOffset", + "lateralOffset" + ], + "properties": { + "kind": { + "type": "string", + "const": "constantOffset" + }, + "distanceDomain": { + "type": "string", + "const": "centerlineStation" + }, + "axisSource": { + "type": "string", + "const": "sampledFrame" + }, + "normalOffset": { + "type": "number" + }, + "lateralOffset": { + "type": "number" + } + } + } + } +}