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"
+ }
+ }
+ }
+ }
+}