From 1e2ed70ba1e07be51e08dc0b2eee9207627946d9 Mon Sep 17 00:00:00 2001 From: Coasterpete <83011124+Coasterpete@users.noreply.github.com> Date: Fri, 19 Jun 2026 19:46:30 -0400 Subject: [PATCH 1/3] Add TrackLayoutPackageV2 import mapper --- .../V2/TrackLayoutPackageV2Json.cs | 72 ++++ .../V2/TrackLayoutPackageV2Mapper.cs | 262 ++++++++++++ .../V2/TrackLayoutPackageV2Validator.cs | 4 +- .../IO/TrackLayoutPackageV2MapperTests.cs | 393 ++++++++++++++++++ .../IO/TrackLayoutPackageV2ValidatorTests.cs | 2 + 5 files changed, 732 insertions(+), 1 deletion(-) create mode 100644 Quantum.IO/TrackLayout/V2/TrackLayoutPackageV2Mapper.cs create mode 100644 Quantum.Tests/IO/TrackLayoutPackageV2MapperTests.cs diff --git a/Quantum.IO/TrackLayout/V2/TrackLayoutPackageV2Json.cs b/Quantum.IO/TrackLayout/V2/TrackLayoutPackageV2Json.cs index 8be6d99..d420284 100644 --- a/Quantum.IO/TrackLayout/V2/TrackLayoutPackageV2Json.cs +++ b/Quantum.IO/TrackLayout/V2/TrackLayoutPackageV2Json.cs @@ -1,4 +1,6 @@ using System; +using System.Collections.Generic; +using System.Globalization; using System.Text.Json; using System.Text.Json.Serialization; @@ -48,6 +50,76 @@ public static TrackLayoutPackageV2Dto Deserialize(string json) return dto; } + public static TrackLayoutPackageV2ImportResult Import(string json) + { + if (json == null) + { + throw new ArgumentNullException(nameof(json)); + } + + try + { + return TrackLayoutPackageV2Mapper.Import(Deserialize(json)); + } + catch (JsonException ex) + { + return TrackLayoutPackageV2ImportResult.Failure( + new[] + { + new TrackLayoutPackageV2ValidationDiagnostic( + TrackLayoutPackageV2ValidationCode.MalformedJson, + "json", + CreateMalformedJsonMessage(ex)) + }); + } + } + + private static string CreateMalformedJsonMessage(JsonException exception) + { + JsonException detail = exception.InnerException as JsonException ?? exception; + string message = exception.Message; + if (!ReferenceEquals(detail, exception)) + { + message += " JSON parser detail: " + detail.Message; + } + + string context = CreateJsonExceptionContext(detail); + if (context.Length != 0) + { + message += " " + context; + } + + return message; + } + + private static string CreateJsonExceptionContext(JsonException exception) + { + var parts = new List(); + if (!string.IsNullOrEmpty(exception.Path)) + { + parts.Add("path '" + exception.Path + "'"); + } + + if (exception.LineNumber.HasValue) + { + parts.Add("line " + exception.LineNumber.Value.ToString(CultureInfo.InvariantCulture)); + } + + if (exception.BytePositionInLine.HasValue) + { + parts.Add( + "byte position " + + exception.BytePositionInLine.Value.ToString(CultureInfo.InvariantCulture)); + } + + if (parts.Count == 0) + { + return string.Empty; + } + + return "Context: " + string.Join(", ", parts) + "."; + } + private static JsonSerializerOptions CreateOptions(bool indented) { return new JsonSerializerOptions diff --git a/Quantum.IO/TrackLayout/V2/TrackLayoutPackageV2Mapper.cs b/Quantum.IO/TrackLayout/V2/TrackLayoutPackageV2Mapper.cs new file mode 100644 index 0000000..33df0d3 --- /dev/null +++ b/Quantum.IO/TrackLayout/V2/TrackLayoutPackageV2Mapper.cs @@ -0,0 +1,262 @@ +using System; +using System.Collections.Generic; +using Quantum.Math; +using Quantum.Track; +using Quantum.Track.Authoring; + +namespace Quantum.IO.TrackLayout.V2 +{ + public sealed class TrackLayoutPackageV2ImportResult + { + public TrackLayoutPackageV2ImportResult( + bool success, + TrackAuthoringDefinition? definition, + HeartlineOffset? heartlineOffset, + IReadOnlyList diagnostics) + { + Success = success; + Definition = definition; + HeartlineOffset = heartlineOffset; + Diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics)); + } + + public bool Success { get; } + + public TrackAuthoringDefinition? Definition { get; } + + public HeartlineOffset? HeartlineOffset { get; } + + public IReadOnlyList Diagnostics { get; } + + internal static TrackLayoutPackageV2ImportResult Failure( + IReadOnlyList diagnostics) + { + return new TrackLayoutPackageV2ImportResult(false, null, null, diagnostics); + } + } + + /// + /// Maps TrackLayoutPackageV2 DTOs into backend authoring definitions. + /// + public static class TrackLayoutPackageV2Mapper + { + public static TrackLayoutPackageV2ImportResult Import(TrackLayoutPackageV2Dto dto) + { + if (dto == null) + { + throw new ArgumentNullException(nameof(dto)); + } + + IReadOnlyList diagnostics = + TrackLayoutPackageV2Validator.Validate(dto); + if (diagnostics.Count != 0) + { + return TrackLayoutPackageV2ImportResult.Failure(diagnostics); + } + + try + { + var sections = new List(dto.Sections.Length); + for (int i = 0; i < dto.Sections.Length; i++) + { + sections.Add(MapSection(dto.Sections[i])); + } + + TrackStartPose startPose = MapStartPose(dto.StartPose); + TrackBankingDefinition? banking = MapBanking(dto.Banking); + TrackAuthoringDefinition definition = banking == null + ? new TrackAuthoringDefinition(sections, startPose) + : new TrackAuthoringDefinition(sections, startPose, banking); + HeartlineOffset? heartlineOffset = MapHeartline(dto.Heartline); + + return new TrackLayoutPackageV2ImportResult( + true, + definition, + heartlineOffset, + Array.Empty()); + } + catch (Exception ex) when ( + ex is ArgumentException || + ex is ArgumentOutOfRangeException || + ex is InvalidOperationException || + ex is NotSupportedException) + { + return TrackLayoutPackageV2ImportResult.Failure( + new[] + { + new TrackLayoutPackageV2ValidationDiagnostic( + TrackLayoutPackageV2ValidationCode.MappingFailed, + "dto", + "Validated TrackLayoutPackageV2 DTO could not be mapped; this likely indicates " + + "validator/mapper parity drift. Mapper detail: " + ex.Message) + }); + } + } + + private static GeometricSectionDefinition MapSection(TrackLayoutSectionV2Dto section) + { + switch (section.Kind) + { + case TrackLayoutPackageV2Vocabulary.StraightSectionKind: + return new StraightSectionDefinition( + section.Id, + section.Length, + section.RollRadians); + + case TrackLayoutPackageV2Vocabulary.ConstantCurvatureSectionKind: + return new ConstantCurvatureSectionDefinition( + section.Id, + section.Length, + section.Radius!.Value, + section.RollRadians); + + case TrackLayoutPackageV2Vocabulary.CurvatureTransitionSectionKind: + return new CurvatureTransitionSectionDefinition( + section.Id, + section.Length, + section.StartCurvature!.Value, + section.EndCurvature!.Value, + MapCurvatureInterpolation(section.InterpolationMode), + section.RollRadians); + + case TrackLayoutPackageV2Vocabulary.SpatialSectionKind: + return new SpatialSectionDefinition( + section.Id, + section.Length, + MapVectors(section.ControlPoints!), + section.Degree!.Value, + section.Weights!, + section.RollRadians); + + default: + throw new NotSupportedException( + "Unsupported TrackLayoutPackageV2 section kind '" + section.Kind + "'."); + } + } + + private static TrackStartPose MapStartPose(TrackStartPoseV2Dto startPose) + { + return new TrackStartPose( + MapVector(startPose.Position), + MapVector(startPose.Tangent), + MapVector(startPose.Normal), + MapVector(startPose.Binormal)); + } + + private static TrackBankingDefinition? MapBanking(TrackBankingV2Dto? banking) + { + if (banking == null) + { + return null; + } + + var keys = new BankingProfileKey[banking.Keys.Length]; + for (int i = 0; i < banking.Keys.Length; i++) + { + TrackBankingKeyV2Dto key = banking.Keys[i]; + keys[i] = new BankingProfileKey( + key.Distance, + key.RollRadians, + MapBankingInterpolation(key.InterpolationToNext)); + } + + return new TrackBankingDefinition(keys); + } + + private static HeartlineOffset? MapHeartline(TrackHeartlineV2Dto? heartline) + { + if (heartline == null) + { + return null; + } + + if (!string.Equals( + heartline.Kind, + TrackLayoutPackageV2Vocabulary.HeartlineKindConstantOffset, + StringComparison.Ordinal) || + !string.Equals( + heartline.DistanceDomain, + TrackLayoutPackageV2Vocabulary.HeartlineDistanceDomainCenterlineStation, + StringComparison.Ordinal) || + !string.Equals( + heartline.AxisSource, + TrackLayoutPackageV2Vocabulary.HeartlineAxisSourceSampledFrame, + StringComparison.Ordinal)) + { + throw new NotSupportedException( + "Only constantOffset heartline offsets over centerlineStation using sampledFrame axes are supported."); + } + + return new HeartlineOffset(heartline.NormalOffset, heartline.LateralOffset); + } + + private static CurvatureTransitionInterpolationMode MapCurvatureInterpolation( + string? interpolationMode) + { + switch (interpolationMode) + { + case TrackLayoutPackageV2Vocabulary.CurvatureInterpolationLinear: + return CurvatureTransitionInterpolationMode.Linear; + + default: + throw new ArgumentOutOfRangeException( + nameof(interpolationMode), + interpolationMode, + "Unsupported curvature transition interpolation mode."); + } + } + + private static BankingProfileInterpolationMode MapBankingInterpolation( + string interpolationToNext) + { + switch (interpolationToNext) + { + case TrackLayoutPackageV2Vocabulary.BankingInterpolationConstant: + return BankingProfileInterpolationMode.Constant; + + case TrackLayoutPackageV2Vocabulary.BankingInterpolationLinear: + return BankingProfileInterpolationMode.Linear; + + case TrackLayoutPackageV2Vocabulary.BankingInterpolationSmoothStep: + return BankingProfileInterpolationMode.SmoothStep; + + case TrackLayoutPackageV2Vocabulary.BankingInterpolationQuadratic: + return BankingProfileInterpolationMode.Quadratic; + + case TrackLayoutPackageV2Vocabulary.BankingInterpolationCubic: + return BankingProfileInterpolationMode.Cubic; + + case TrackLayoutPackageV2Vocabulary.BankingInterpolationQuartic: + return BankingProfileInterpolationMode.Quartic; + + case TrackLayoutPackageV2Vocabulary.BankingInterpolationQuintic: + return BankingProfileInterpolationMode.Quintic; + + case TrackLayoutPackageV2Vocabulary.BankingInterpolationSinusoidal: + return BankingProfileInterpolationMode.Sinusoidal; + + default: + throw new ArgumentOutOfRangeException( + nameof(interpolationToNext), + interpolationToNext, + "Unsupported banking interpolation mode."); + } + } + + private static Vector3d[] MapVectors(TrackLayoutVector3dV2Dto[] vectors) + { + var result = new Vector3d[vectors.Length]; + for (int i = 0; i < vectors.Length; i++) + { + result[i] = MapVector(vectors[i]); + } + + return result; + } + + private static Vector3d MapVector(TrackLayoutVector3dV2Dto vector) + { + return new Vector3d(vector.X, vector.Y, vector.Z); + } + } +} diff --git a/Quantum.IO/TrackLayout/V2/TrackLayoutPackageV2Validator.cs b/Quantum.IO/TrackLayout/V2/TrackLayoutPackageV2Validator.cs index 706cd47..1e0742a 100644 --- a/Quantum.IO/TrackLayout/V2/TrackLayoutPackageV2Validator.cs +++ b/Quantum.IO/TrackLayout/V2/TrackLayoutPackageV2Validator.cs @@ -33,7 +33,9 @@ public enum TrackLayoutPackageV2ValidationCode InvalidBankingDomain = 24, InvalidHeartlineKind = 25, InvalidHeartlineDistanceDomain = 26, - InvalidHeartlineAxisSource = 27 + InvalidHeartlineAxisSource = 27, + MalformedJson = 28, + MappingFailed = 29 } public sealed class TrackLayoutPackageV2ValidationDiagnostic diff --git a/Quantum.Tests/IO/TrackLayoutPackageV2MapperTests.cs b/Quantum.Tests/IO/TrackLayoutPackageV2MapperTests.cs new file mode 100644 index 0000000..24d1199 --- /dev/null +++ b/Quantum.Tests/IO/TrackLayoutPackageV2MapperTests.cs @@ -0,0 +1,393 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Quantum.IO.TrackLayout.V2; +using Quantum.Math; +using Quantum.Track; +using Quantum.Track.Authoring; + +namespace Quantum.Tests; + +public sealed class TrackLayoutPackageV2MapperTests +{ + private const double Tolerance = 1e-6; + + [Fact] + public void JsonImport_MinimalValidV2Package_MapsCompilesAndEvaluates() + { + string json = TrackLayoutPackageV2Json.Serialize(CreateMinimalDto()); + + TrackLayoutPackageV2ImportResult result = TrackLayoutPackageV2Json.Import(json); + + Assert.True(result.Success); + Assert.Empty(result.Diagnostics); + Assert.NotNull(result.Definition); + Assert.False(result.HeartlineOffset.HasValue); + TrackAuthoringCompilation compilation = TrackAuthoringDocumentBuilder.Compile( + result.Definition!); + var evaluator = new TrackEvaluator(compilation.Document); + TrackFrame frame = evaluator.EvaluateFrameAtDistance(6.0); + + Assert.Equal(12.0, compilation.TotalLength); + Assert.NotNull(compilation.Runtime); + AssertVectorNear(new Vector3d(6.0, 0.0, 0.0), frame.Position); + AssertVectorNear(Vector3d.UnitX, frame.Tangent); + } + + [Fact] + public void Import_RepresentativeSections_MapsSupportedAuthoringSectionKinds() + { + TrackLayoutPackageV2Dto dto = CreateRepresentativeDto(); + dto.Banking = null; + + TrackLayoutPackageV2ImportResult result = TrackLayoutPackageV2Mapper.Import(dto); + + Assert.True(result.Success); + Assert.NotNull(result.Definition); + IReadOnlyList sections = result.Definition!.Sections; + Assert.Equal(4, sections.Count); + + StraightSectionDefinition straight = Assert.IsType(sections[0]); + Assert.Equal("entry", straight.Id); + Assert.Equal(10.0, straight.Length); + Assert.Equal(0.1, straight.RollRadians); + + ConstantCurvatureSectionDefinition arc = + Assert.IsType(sections[1]); + Assert.Equal("turn", arc.Id); + Assert.Equal(-30.0, arc.Radius); + + CurvatureTransitionSectionDefinition transition = + Assert.IsType(sections[2]); + Assert.Equal("transition", transition.Id); + Assert.Equal(0.02, transition.StartCurvature); + Assert.Equal(-0.01, transition.EndCurvature); + Assert.Equal(CurvatureTransitionInterpolationMode.Linear, transition.InterpolationMode); + + SpatialSectionDefinition spatial = Assert.IsType(sections[3]); + Assert.Equal("spatial", spatial.Id); + Assert.Equal(3, spatial.Degree); + Assert.Equal(4, spatial.ControlPoints.Count); + AssertVectorNear(new Vector3d(6.0, 0.0, 0.0), spatial.ControlPoints[3]); + Assert.Equal(new[] { 1.0, 1.0, 1.0, 1.0 }, spatial.Weights.ToArray()); + + TrackAuthoringCompilation compilation = TrackAuthoringDocumentBuilder.Compile( + result.Definition); + Assert.Equal(36.0, compilation.TotalLength); + Assert.NotNull(compilation.Runtime); + } + + [Fact] + public void Import_BankingAndHeartline_MapsToBackendOptInRuntimeContracts() + { + TrackLayoutPackageV2Dto dto = CreateBankedHeartlineDto(); + + TrackLayoutPackageV2ImportResult result = TrackLayoutPackageV2Mapper.Import(dto); + + Assert.True(result.Success); + Assert.NotNull(result.Definition); + Assert.NotNull(result.Definition!.Banking); + Assert.True(result.HeartlineOffset.HasValue); + + Assert.Equal(2, result.Definition.Banking!.Keys.Count); + Assert.Equal(BankingProfileInterpolationMode.Constant, result.Definition.Banking.Keys[0].InterpolationToNext); + HeartlineOffset heartlineOffset = result.HeartlineOffset!.Value; + AssertDoubleNear(1.5, heartlineOffset.NormalOffsetMeters); + AssertDoubleNear(-0.25, heartlineOffset.LateralOffsetMeters); + + TrackAuthoringCompilation compilation = TrackAuthoringDocumentBuilder.Compile( + result.Definition); + var evaluator = new TrackEvaluator(compilation.Document); + HeartlineFrame heartlineFrame = Assert.Single(HeartlineSampler.SampleAtDistances( + evaluator, + compilation.BankingProfile, + heartlineOffset, + new[] { 5.0 })); + + AssertVectorNear(new Vector3d(5.0, 0.25, 1.5), heartlineFrame.Position); + } + + [Fact] + public void Import_InvalidV2Package_ReturnsValidationDiagnosticsWithoutDefinition() + { + TrackLayoutPackageV2Dto dto = CreateMinimalDto(); + dto.Sections = Array.Empty(); + + TrackLayoutPackageV2ImportResult result = TrackLayoutPackageV2Mapper.Import(dto); + + Assert.False(result.Success); + Assert.Null(result.Definition); + Assert.False(result.HeartlineOffset.HasValue); + AssertDiagnostic( + result.Diagnostics, + TrackLayoutPackageV2ValidationCode.EmptySections, + "sections"); + } + + [Fact] + public void Import_DuplicateUnknownAndInvalidSections_AreRejectedByValidation() + { + TrackLayoutPackageV2Dto duplicate = CreateMinimalDto(); + duplicate.Sections = new[] + { + CreateStraightSection("entry", 2.0), + CreateStraightSection("entry", 3.0) + }; + + TrackLayoutPackageV2Dto unknown = CreateMinimalDto(); + unknown.Sections = new[] + { + new TrackLayoutSectionV2Dto + { + Kind = "legacy", + Id = "legacy", + Length = 1.0, + RollRadians = 0.0 + } + }; + + TrackLayoutPackageV2Dto invalid = CreateMinimalDto(); + invalid.Sections = new[] + { + CreateStraightSection("invalid-length", 0.0) + }; + + TrackLayoutPackageV2ImportResult duplicateResult = TrackLayoutPackageV2Mapper.Import(duplicate); + TrackLayoutPackageV2ImportResult unknownResult = TrackLayoutPackageV2Mapper.Import(unknown); + TrackLayoutPackageV2ImportResult invalidResult = TrackLayoutPackageV2Mapper.Import(invalid); + + AssertRejectedByValidation( + duplicateResult, + TrackLayoutPackageV2ValidationCode.DuplicateSectionId, + "sections[1].id"); + AssertRejectedByValidation( + unknownResult, + TrackLayoutPackageV2ValidationCode.UnknownSectionKind, + "sections[0].kind"); + AssertRejectedByValidation( + invalidResult, + TrackLayoutPackageV2ValidationCode.NonPositiveLength, + "sections[0].length"); + } + + [Fact] + public void JsonImport_MalformedJson_ReturnsDiagnosticWithoutDefinition() + { + TrackLayoutPackageV2ImportResult result = TrackLayoutPackageV2Json.Import("{"); + + Assert.False(result.Success); + Assert.Null(result.Definition); + Assert.False(result.HeartlineOffset.HasValue); + TrackLayoutPackageV2ValidationDiagnostic diagnostic = AssertDiagnostic( + result.Diagnostics, + TrackLayoutPackageV2ValidationCode.MalformedJson, + "json"); + Assert.Contains( + "Failed to deserialize TrackLayoutPackageV2Dto", + diagnostic.Message, + StringComparison.Ordinal); + } + + 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 CreateRepresentativeDto() + { + return new TrackLayoutPackageV2Dto + { + Metadata = new TrackLayoutMetadataV2Dto + { + Units = "meters", + SourceName = "representative", + LayoutId = "layout.m148.import-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 TrackLayoutPackageV2Dto CreateBankedHeartlineDto() + { + return new TrackLayoutPackageV2Dto + { + Metadata = new TrackLayoutMetadataV2Dto + { + Units = "meters", + SourceName = "Banked heartline V2 import", + LayoutId = "layout.m148.banked-heartline" + }, + Sections = new[] + { + CreateStraightSection("entry", 10.0) + }, + Banking = new TrackBankingV2Dto + { + Keys = new[] + { + new TrackBankingKeyV2Dto + { + Distance = 0.0, + RollRadians = System.Math.PI * 0.5, + InterpolationToNext = TrackLayoutPackageV2Vocabulary.BankingInterpolationConstant + }, + new TrackBankingKeyV2Dto + { + Distance = 10.0, + RollRadians = System.Math.PI * 0.5, + InterpolationToNext = TrackLayoutPackageV2Vocabulary.BankingInterpolationConstant + } + } + }, + Heartline = new TrackHeartlineV2Dto + { + Kind = TrackLayoutPackageV2Vocabulary.HeartlineKindConstantOffset, + DistanceDomain = TrackLayoutPackageV2Vocabulary.HeartlineDistanceDomainCenterlineStation, + AxisSource = TrackLayoutPackageV2Vocabulary.HeartlineAxisSourceSampledFrame, + NormalOffset = 1.5, + LateralOffset = -0.25 + } + }; + } + + private static TrackLayoutSectionV2Dto CreateStraightSection(string id, double length) + { + return new TrackLayoutSectionV2Dto + { + Kind = TrackLayoutPackageV2Vocabulary.StraightSectionKind, + Id = id, + Length = length, + RollRadians = 0.0 + }; + } + + private static void AssertRejectedByValidation( + TrackLayoutPackageV2ImportResult result, + TrackLayoutPackageV2ValidationCode code, + string path) + { + Assert.False(result.Success); + Assert.Null(result.Definition); + Assert.False(result.HeartlineOffset.HasValue); + AssertDiagnostic(result.Diagnostics, code, path); + } + + 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!; + } + + private static void AssertVectorNear(Vector3d expected, Vector3d actual) + { + AssertDoubleNear(expected.X, actual.X); + AssertDoubleNear(expected.Y, actual.Y); + AssertDoubleNear(expected.Z, actual.Z); + } + + private static void AssertDoubleNear(double expected, double actual) + { + Assert.InRange(System.Math.Abs(expected - actual), 0.0, Tolerance); + } +} diff --git a/Quantum.Tests/IO/TrackLayoutPackageV2ValidatorTests.cs b/Quantum.Tests/IO/TrackLayoutPackageV2ValidatorTests.cs index d5bd45c..44e73ed 100644 --- a/Quantum.Tests/IO/TrackLayoutPackageV2ValidatorTests.cs +++ b/Quantum.Tests/IO/TrackLayoutPackageV2ValidatorTests.cs @@ -46,6 +46,8 @@ public void ValidationCodeValues_AreStable() Assert.Equal(25, (int)TrackLayoutPackageV2ValidationCode.InvalidHeartlineKind); Assert.Equal(26, (int)TrackLayoutPackageV2ValidationCode.InvalidHeartlineDistanceDomain); Assert.Equal(27, (int)TrackLayoutPackageV2ValidationCode.InvalidHeartlineAxisSource); + Assert.Equal(28, (int)TrackLayoutPackageV2ValidationCode.MalformedJson); + Assert.Equal(29, (int)TrackLayoutPackageV2ValidationCode.MappingFailed); } [Fact] From 462b4f9e7c72bc33082c4e470ed8b91d94a2d6c4 Mon Sep 17 00:00:00 2001 From: Coasterpete <83011124+Coasterpete@users.noreply.github.com> Date: Fri, 19 Jun 2026 19:51:59 -0400 Subject: [PATCH 2/3] Stabilize BankingProfile browser artifact paths --- Quantum.Debug/BankingProfileBrowserCommand.cs | 9 +++++++-- Quantum.Tests/Debug/BankingProfileBrowserCommandTests.cs | 7 ++++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/Quantum.Debug/BankingProfileBrowserCommand.cs b/Quantum.Debug/BankingProfileBrowserCommand.cs index 3bf176f..9d5d61c 100644 --- a/Quantum.Debug/BankingProfileBrowserCommand.cs +++ b/Quantum.Debug/BankingProfileBrowserCommand.cs @@ -101,10 +101,15 @@ private static string BuildHtml( string outputHtmlPath, JsonElement artifactRoot) { + string? outputDirectory = Path.GetDirectoryName(outputHtmlPath); + string payloadPathBase = string.IsNullOrEmpty(outputDirectory) + ? Environment.CurrentDirectory + : outputDirectory; + var payload = new BankingProfileBrowserPayload { - SourcePath = ToDisplayPath(Path.GetRelativePath(Environment.CurrentDirectory, diagnosticsJsonPath)), - OutputPath = ToDisplayPath(Path.GetRelativePath(Environment.CurrentDirectory, outputHtmlPath)), + SourcePath = ToDisplayPath(Path.GetRelativePath(payloadPathBase, diagnosticsJsonPath)), + OutputPath = ToDisplayPath(Path.GetRelativePath(payloadPathBase, outputHtmlPath)), Artifact = artifactRoot }; diff --git a/Quantum.Tests/Debug/BankingProfileBrowserCommandTests.cs b/Quantum.Tests/Debug/BankingProfileBrowserCommandTests.cs index 0551f77..742d5f8 100644 --- a/Quantum.Tests/Debug/BankingProfileBrowserCommandTests.cs +++ b/Quantum.Tests/Debug/BankingProfileBrowserCommandTests.cs @@ -115,7 +115,12 @@ public void Run_ValidDiagnosticsJson_EmbedsParseableDiagnosticsJsonPayload() Assert.Equal("Linear", samples[1].GetProperty("interpolationMode").GetString()); Assert.Equal("Constant", samples[3].GetProperty("interpolationMode").GetString()); Assert.True(samples[1].TryGetProperty("approximateRollSlopeRadPerMeter", out _)); - Assert.Contains("banking-profile-diagnostics.sample.json", root.GetProperty("sourcePath").GetString()); + Assert.Equal( + "banking-profile-diagnostics.sample.json", + root.GetProperty("sourcePath").GetString()); + Assert.Equal( + "banking-profile.browser.html", + root.GetProperty("outputPath").GetString()); } finally { From 1263ed86f4d8eb6937bdcca2520d4a10681b8bdb Mon Sep 17 00:00:00 2001 From: Coasterpete <83011124+Coasterpete@users.noreply.github.com> Date: Fri, 19 Jun 2026 20:21:39 -0400 Subject: [PATCH 3/3] Add TrackLayoutPackageV2 debug viewport command --- Quantum.Debug/DebugCommandHelp.cs | 18 + Quantum.Debug/DebugCommandParser.cs | 7 + ...apshotV1FromTrackLayoutPackageV2Command.cs | 566 ++++++++++++++++++ Quantum.Debug/Program.cs | 18 + .../V1/DebugViewportSnapshotV1Mapper.cs | 2 + Quantum.Tests/Debug/DebugCommandHelpTests.cs | 23 + .../Debug/DebugCommandParserTests.cs | 11 + ...tV1FromTrackLayoutPackageV2CommandTests.cs | 370 ++++++++++++ Quantum.Track/TrackFrameAxisType.cs | 3 +- README.md | 7 + 10 files changed, 1024 insertions(+), 1 deletion(-) create mode 100644 Quantum.Debug/DebugViewportSnapshotV1FromTrackLayoutPackageV2Command.cs create mode 100644 Quantum.Tests/Debug/DebugViewportSnapshotV1FromTrackLayoutPackageV2CommandTests.cs diff --git a/Quantum.Debug/DebugCommandHelp.cs b/Quantum.Debug/DebugCommandHelp.cs index aa45464..c1ee6b5 100644 --- a/Quantum.Debug/DebugCommandHelp.cs +++ b/Quantum.Debug/DebugCommandHelp.cs @@ -95,6 +95,24 @@ public static class DebugCommandHelp "dotnet run --project Quantum.Debug -- debug-viewport-snapshot-v1-from-csv Quantum.Tests/IO/Fixtures/Milestone7.synthetic.straight_line.centerline_frames.csv", "dotnet run --project Quantum.Debug -- debug-viewport-snapshot-v1-from-csv Quantum.Tests/IO/Fixtures/Milestone7.synthetic.straight_line.centerline_frames.csv artifacts/debug-viewport/Milestone7.synthetic.straight_line.snapshot.json" }), + new DebugCommandHelpEntry( + name: DebugViewportSnapshotV1FromTrackLayoutPackageV2Command.CommandName, + usage: "debug-viewport-snapshot-v1-from-track-layout-package-v2 [outputJsonPath]", + summary: "Import a TrackLayoutPackageV2 JSON layout and export DebugViewportSnapshotV1 JSON.", + arguments: new[] + { + "inputJsonPath: Required TrackLayoutPackageV2 JSON path.", + "outputJsonPath: Optional JSON output path. Defaults next to the input JSON with " + + DebugViewportSnapshotV1FromTrackLayoutPackageV2Command.DefaultOutputExtension + " appended.", + "The command validates and maps through the TrackLayoutPackageV2 importer, compiles the authored layout, samples backend TrackFrame data, evaluates simple train boxes when the track is long enough, and validates the resulting DebugViewportSnapshotV1 payload.", + "Authored banking uses the existing opt-in BankingProfile sampling path. Authored heartline offsets are represented as diagnostic connector lines because DebugViewportSnapshotV1 has no dedicated heartline layer.", + DebugViewportPreviewIndexNote + }, + examples: new[] + { + "dotnet run --project Quantum.Debug -- debug-viewport-snapshot-v1-from-track-layout-package-v2 artifacts/layouts/minimal-v2.json", + "dotnet run --project Quantum.Debug -- debug-viewport-snapshot-v1-from-track-layout-package-v2 artifacts/layouts/minimal-v2.json artifacts/debug-viewport/minimal-v2.snapshot.json" + }), new DebugCommandHelpEntry( name: "debug-viewport-snapshot-v1-validate", usage: "debug-viewport-snapshot-v1-validate ", diff --git a/Quantum.Debug/DebugCommandParser.cs b/Quantum.Debug/DebugCommandParser.cs index e6f0d01..8c8ab38 100644 --- a/Quantum.Debug/DebugCommandParser.cs +++ b/Quantum.Debug/DebugCommandParser.cs @@ -12,6 +12,7 @@ public enum DebugCommandKind MeshExportV1Sample, DebugViewportSnapshotV1, DebugViewportSnapshotV1FromCsv, + DebugViewportSnapshotV1FromTrackLayoutPackageV2, DebugViewportSnapshotV1Validate, DebugViewportSnapshotV1Svg, DebugViewportSnapshotV1Gallery, @@ -85,6 +86,12 @@ public static bool TryParse( return true; } + if (string.Equals(args[0], DebugViewportSnapshotV1FromTrackLayoutPackageV2Command.CommandName, StringComparison.OrdinalIgnoreCase)) + { + command = DebugCommandKind.DebugViewportSnapshotV1FromTrackLayoutPackageV2; + return true; + } + if (string.Equals(args[0], "debug-viewport-snapshot-v1-validate", StringComparison.OrdinalIgnoreCase)) { command = DebugCommandKind.DebugViewportSnapshotV1Validate; diff --git a/Quantum.Debug/DebugViewportSnapshotV1FromTrackLayoutPackageV2Command.cs b/Quantum.Debug/DebugViewportSnapshotV1FromTrackLayoutPackageV2Command.cs new file mode 100644 index 0000000..45d8494 --- /dev/null +++ b/Quantum.Debug/DebugViewportSnapshotV1FromTrackLayoutPackageV2Command.cs @@ -0,0 +1,566 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.Json; +using Quantum.IO.DebugViewport.V1; +using Quantum.IO.TrackLayout.V2; +using Quantum.Track; +using Quantum.Track.Authoring; + +namespace Quantum.Debug +{ + public sealed class DebugViewportSnapshotV1FromTrackLayoutPackageV2ExportDiagnostic + { + public DebugViewportSnapshotV1FromTrackLayoutPackageV2ExportDiagnostic( + string code, + string path, + string message) + { + Code = string.IsNullOrWhiteSpace(code) ? "Unknown" : code; + Path = path ?? string.Empty; + Message = message ?? string.Empty; + } + + public string Code { get; } + + public string Path { get; } + + public string Message { get; } + } + + public sealed class DebugViewportSnapshotV1FromTrackLayoutPackageV2ExportResult + { + private DebugViewportSnapshotV1FromTrackLayoutPackageV2ExportResult( + bool success, + DebugViewportSnapshotV1Dto? snapshot, + IReadOnlyList diagnostics) + { + Success = success; + Snapshot = snapshot; + Diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics)); + } + + public bool Success { get; } + + public DebugViewportSnapshotV1Dto? Snapshot { get; } + + public IReadOnlyList Diagnostics { get; } + + internal static DebugViewportSnapshotV1FromTrackLayoutPackageV2ExportResult Succeeded( + DebugViewportSnapshotV1Dto snapshot) + { + return new DebugViewportSnapshotV1FromTrackLayoutPackageV2ExportResult( + true, + snapshot ?? throw new ArgumentNullException(nameof(snapshot)), + Array.Empty()); + } + + internal static DebugViewportSnapshotV1FromTrackLayoutPackageV2ExportResult Failed( + IReadOnlyList diagnostics) + { + return new DebugViewportSnapshotV1FromTrackLayoutPackageV2ExportResult( + false, + null, + diagnostics); + } + } + + public static class DebugViewportSnapshotV1FromTrackLayoutPackageV2Command + { + public const string CommandName = "debug-viewport-snapshot-v1-from-track-layout-package-v2"; + + internal const string DefaultOutputExtension = ".debug-viewport-snapshot-v1.json"; + + private const double SampleIntervalMeters = 3.0; + private const int MaximumSampleCount = 257; + private const double AxisLengthMeters = 4.0; + private const int MaximumTrainCarCount = 5; + private const double TrainCarSpacingMeters = 6.0; + private const double TrainCarLengthMeters = 5.0; + private const double TrainCarWidthMeters = 1.8; + private const double TrainCarHeightMeters = 2.2; + private const double TrainBogieSpacingMeters = 4.0; + private const double MinimumLineLength = 1e-9; + + private static readonly UTF8Encoding Utf8NoBom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + + public static int Run(string inputJsonPath, string? outputJsonPath = null) + { + return Run(inputJsonPath, outputJsonPath, Console.Out); + } + + public static int Run(string inputJsonPath, string? outputJsonPath, TextWriter output) + { + if (output == null) + { + throw new ArgumentNullException(nameof(output)); + } + + if (string.IsNullOrWhiteSpace(inputJsonPath)) + { + output.WriteLine("inputJsonPath is required."); + return 1; + } + + string resolvedInputPath = Path.GetFullPath(inputJsonPath); + string json; + try + { + json = File.ReadAllText(resolvedInputPath); + } + catch (Exception ex) when (IsReadOrWriteException(ex)) + { + output.WriteLine("Failed to read TrackLayoutPackageV2 JSON."); + output.WriteLine(ex.Message); + return 1; + } + + DebugViewportSnapshotV1FromTrackLayoutPackageV2ExportResult result = + ExportJson(json, ResolveSourceFixtureName(inputJsonPath)); + if (!result.Success || result.Snapshot == null) + { + PrintDiagnostics(result.Diagnostics, output); + return 1; + } + + string snapshotJson = DebugViewportSnapshotV1Json.Serialize(result.Snapshot, indented: true); + string resolvedOutputPath = ResolveOutputPath(resolvedInputPath, outputJsonPath); + string? parentDirectory = Path.GetDirectoryName(resolvedOutputPath); + + if (!string.IsNullOrEmpty(parentDirectory)) + { + Directory.CreateDirectory(parentDirectory); + } + + try + { + File.WriteAllText(resolvedOutputPath, snapshotJson, Utf8NoBom); + } + catch (Exception ex) when (IsReadOrWriteException(ex)) + { + output.WriteLine("Failed to write DebugViewportSnapshotV1 JSON."); + output.WriteLine(ex.Message); + return 1; + } + + output.WriteLine( + "Wrote TrackLayoutPackageV2 DebugViewportSnapshotV1 snapshot to '" + + resolvedOutputPath + + "'."); + DebugViewportSnapshotPreviewIndex.TryWriteForGeneratedOutput(resolvedOutputPath, output); + return 0; + } + + public static DebugViewportSnapshotV1FromTrackLayoutPackageV2ExportResult ExportJson( + string json, + string? sourceName = null) + { + if (json == null) + { + throw new ArgumentNullException(nameof(json)); + } + + TrackLayoutPackageV2Dto dto; + try + { + dto = TrackLayoutPackageV2Json.Deserialize(json); + } + catch (JsonException) + { + TrackLayoutPackageV2ImportResult malformedImport = TrackLayoutPackageV2Json.Import(json); + return DebugViewportSnapshotV1FromTrackLayoutPackageV2ExportResult.Failed( + MapImportDiagnostics(malformedImport.Diagnostics)); + } + + TrackLayoutPackageV2ImportResult importResult = TrackLayoutPackageV2Mapper.Import(dto); + if (!importResult.Success || importResult.Definition == null) + { + return DebugViewportSnapshotV1FromTrackLayoutPackageV2ExportResult.Failed( + MapImportDiagnostics(importResult.Diagnostics)); + } + + return ExportImportedPackage(dto, importResult, sourceName); + } + + public static DebugViewportSnapshotV1FromTrackLayoutPackageV2ExportResult Export( + TrackLayoutPackageV2Dto dto, + string? sourceName = null) + { + if (dto == null) + { + throw new ArgumentNullException(nameof(dto)); + } + + TrackLayoutPackageV2ImportResult importResult = TrackLayoutPackageV2Mapper.Import(dto); + if (!importResult.Success || importResult.Definition == null) + { + return DebugViewportSnapshotV1FromTrackLayoutPackageV2ExportResult.Failed( + MapImportDiagnostics(importResult.Diagnostics)); + } + + return ExportImportedPackage(dto, importResult, sourceName); + } + + private static DebugViewportSnapshotV1FromTrackLayoutPackageV2ExportResult ExportImportedPackage( + TrackLayoutPackageV2Dto dto, + TrackLayoutPackageV2ImportResult importResult, + string? sourceName) + { + TrackAuthoringCompilation compilation; + try + { + compilation = TrackAuthoringDocumentBuilder.Compile(importResult.Definition!); + } + catch (Exception ex) when (IsCompilationOrEvaluationException(ex)) + { + return DebugViewportSnapshotV1FromTrackLayoutPackageV2ExportResult.Failed( + SingleDiagnostic( + "TrackLayoutPackageV2.CompilationFailed", + "track", + ex.Message)); + } + + try + { + bool hasAuthoredBanking = importResult.Definition!.Banking != null; + var evaluator = new TrackEvaluator(compilation.Runtime); + double totalLength = evaluator.GetBoundTrackTotalLength(); + double[] sampleDistances = BuildSampleDistances(totalLength); + TrackFrame[] sampledFrames = hasAuthoredBanking + ? BankingProfileSampler.SampleFramesAtDistances( + evaluator, + compilation.BankingProfile, + sampleDistances) + : evaluator.EvaluateFramesAtDistances(sampleDistances); + DebugLineSegment[] lines = BuildDebugLines( + evaluator, + compilation.BankingProfile, + sampledFrames, + sampleDistances, + importResult.HeartlineOffset, + hasAuthoredBanking); + TrainPoseResult? trainPose = TryEvaluateTrainPose( + evaluator, + compilation.BankingProfile, + hasAuthoredBanking, + totalLength, + out DebugViewportBoxSource[] boxes); + + DebugViewportSnapshotV1Dto snapshot = DebugViewportSnapshotV1Mapper.Export( + new DebugViewportSnapshotV1Source + { + Units = ResolveUnits(dto), + SourceFixtureName = ResolveSnapshotSourceName(dto, sourceName), + SampledFrames = sampledFrames, + Lines = lines, + Boxes = boxes, + TrainPose = trainPose + }); + + IReadOnlyList validationDiagnostics = + DebugViewportSnapshotV1Validator.Validate(snapshot); + if (validationDiagnostics.Count != 0) + { + return DebugViewportSnapshotV1FromTrackLayoutPackageV2ExportResult.Failed( + MapSnapshotDiagnostics(validationDiagnostics)); + } + + return DebugViewportSnapshotV1FromTrackLayoutPackageV2ExportResult.Succeeded(snapshot); + } + catch (Exception ex) when (IsCompilationOrEvaluationException(ex)) + { + return DebugViewportSnapshotV1FromTrackLayoutPackageV2ExportResult.Failed( + SingleDiagnostic( + "TrackLayoutPackageV2.EvaluationFailed", + "track", + ex.Message)); + } + } + + private static double[] BuildSampleDistances(double totalLength) + { + if (double.IsNaN(totalLength) || double.IsInfinity(totalLength) || totalLength <= 0.0) + { + throw new InvalidOperationException("Compiled track length must be finite and greater than zero."); + } + + int intervalCount = System.Math.Max(1, (int)System.Math.Ceiling(totalLength / SampleIntervalMeters)); + int sampleCount = System.Math.Min(MaximumSampleCount, intervalCount + 1); + var distances = new double[sampleCount]; + + if (sampleCount == intervalCount + 1) + { + for (int i = 0; i < sampleCount - 1; i++) + { + distances[i] = System.Math.Min(i * SampleIntervalMeters, totalLength); + } + } + else + { + double interval = totalLength / (sampleCount - 1); + for (int i = 0; i < sampleCount - 1; i++) + { + distances[i] = i * interval; + } + } + + distances[sampleCount - 1] = totalLength; + return distances; + } + + private static DebugLineSegment[] BuildDebugLines( + TrackEvaluator evaluator, + BankingProfile bankingProfile, + TrackFrame[] sampledFrames, + IReadOnlyList sampleDistances, + HeartlineOffset? heartlineOffset, + bool useBankingProfile) + { + var lines = new List(); + if (sampledFrames.Length != 0) + { + lines.AddRange(TrackFrameDebugGizmoBuilder.BuildAxes( + sampledFrames[sampledFrames.Length / 2], + AxisLengthMeters)); + } + + if (!heartlineOffset.HasValue) + { + return lines.ToArray(); + } + + HeartlineFrame[] heartlineFrames = useBankingProfile + ? HeartlineSampler.SampleAtDistances( + evaluator, + bankingProfile, + heartlineOffset.Value, + sampleDistances) + : HeartlineSampler.SampleAtDistances( + evaluator, + heartlineOffset.Value, + sampleDistances); + + for (int i = 0; i < heartlineFrames.Length; i++) + { + HeartlineFrame heartlineFrame = heartlineFrames[i]; + if ((heartlineFrame.Position - heartlineFrame.CenterlinePosition).Length <= MinimumLineLength) + { + continue; + } + + lines.Add(new DebugLineSegment( + heartlineFrame.CenterlinePosition, + heartlineFrame.Position, + TrackFrameAxisType.Diagnostic)); + } + + return lines.ToArray(); + } + + private static TrainPoseResult? TryEvaluateTrainPose( + TrackEvaluator evaluator, + BankingProfile bankingProfile, + bool useBankingProfile, + double totalLength, + out DebugViewportBoxSource[] boxes) + { + boxes = Array.Empty(); + if (totalLength < TrainBogieSpacingMeters) + { + return null; + } + + double leadDistance = totalLength - (TrainBogieSpacingMeters * 0.5); + int carCount = 1 + (int)System.Math.Floor((totalLength - TrainBogieSpacingMeters) / TrainCarSpacingMeters); + carCount = System.Math.Max(1, System.Math.Min(MaximumTrainCarCount, carCount)); + + var trainDefinition = new TrainConsistDefinition( + carCount: carCount, + carSpacing: TrainCarSpacingMeters, + carLength: TrainCarLengthMeters, + carWidth: TrainCarWidthMeters, + carHeight: TrainCarHeightMeters, + bogieSpacing: TrainBogieSpacingMeters, + wheelLayout: new TrainWheelLayout( + wheelCountPerBogie: 2, + wheelRadius: 0.45, + wheelWidth: 0.25, + axleSpacing: 1.1)); + var provider = new TrainCarTransformProvider(evaluator); + TrainPoseResult trainPose = useBankingProfile + ? provider.EvaluateTrainPose(leadDistance, trainDefinition, bankingProfile) + : provider.EvaluateTrainPose(leadDistance, trainDefinition); + + boxes = BuildTrainBodyBoxes( + trainPose, + useBankingProfile + ? DebugViewportSnapshotV1Vocabulary.TrainBodyBankingProfileRole + : DebugViewportSnapshotV1Vocabulary.TrainBodyRole); + return trainPose; + } + + private static DebugViewportBoxSource[] BuildTrainBodyBoxes( + TrainPoseResult trainPose, + string role) + { + IReadOnlyList cars = trainPose.CarsReadOnly; + TrainCarGeometry geometry = trainPose.Definition.CarGeometry; + var boxes = new DebugViewportBoxSource[cars.Count]; + + for (int i = 0; i < cars.Count; i++) + { + boxes[i] = new DebugViewportBoxSource( + role: role, + label: "v2-car-" + i.ToString(CultureInfo.InvariantCulture), + frame: cars[i].Body.ArticulatedFrame, + length: geometry.Length, + width: geometry.Width, + height: geometry.Height); + } + + return boxes; + } + + private static IReadOnlyList + MapImportDiagnostics(IReadOnlyList diagnostics) + { + if (diagnostics.Count == 0) + { + return SingleDiagnostic( + "TrackLayoutPackageV2.ImportFailed", + "dto", + "TrackLayoutPackageV2 import failed without diagnostics."); + } + + return diagnostics + .Select(diagnostic => new DebugViewportSnapshotV1FromTrackLayoutPackageV2ExportDiagnostic( + "TrackLayoutPackageV2." + diagnostic.Code, + diagnostic.Path, + diagnostic.Message)) + .ToArray(); + } + + private static IReadOnlyList + MapSnapshotDiagnostics(IReadOnlyList diagnostics) + { + return diagnostics + .Select(diagnostic => new DebugViewportSnapshotV1FromTrackLayoutPackageV2ExportDiagnostic( + "DebugViewportSnapshotV1." + diagnostic.Code, + diagnostic.Path, + diagnostic.Message)) + .ToArray(); + } + + private static IReadOnlyList + SingleDiagnostic(string code, string path, string message) + { + return new[] + { + new DebugViewportSnapshotV1FromTrackLayoutPackageV2ExportDiagnostic( + code, + path, + message) + }; + } + + private static void PrintDiagnostics( + IReadOnlyList diagnostics, + TextWriter output) + { + output.WriteLine("Failed to export TrackLayoutPackageV2 as DebugViewportSnapshotV1."); + + if (diagnostics.Count == 0) + { + output.WriteLine("- Unknown at track: Export failed without diagnostics."); + return; + } + + for (int i = 0; i < diagnostics.Count; i++) + { + DebugViewportSnapshotV1FromTrackLayoutPackageV2ExportDiagnostic diagnostic = diagnostics[i]; + output.WriteLine( + "- " + + diagnostic.Code + + " at " + + diagnostic.Path + + ": " + + diagnostic.Message); + } + } + + private static string ResolveUnits(TrackLayoutPackageV2Dto dto) + { + string? units = dto.Metadata?.Units; + return string.IsNullOrWhiteSpace(units) ? "meters" : units!; + } + + private static string ResolveSnapshotSourceName( + TrackLayoutPackageV2Dto dto, + string? sourceName) + { + string? metadataSourceName = dto.Metadata?.SourceName; + if (!string.IsNullOrWhiteSpace(metadataSourceName)) + { + return metadataSourceName!; + } + + string? layoutId = dto.Metadata?.LayoutId; + if (!string.IsNullOrWhiteSpace(layoutId)) + { + return layoutId!; + } + + if (!string.IsNullOrWhiteSpace(sourceName)) + { + return sourceName!; + } + + return "TrackLayoutPackageV2"; + } + + private static string ResolveOutputPath( + string resolvedInputPath, + string? outputJsonPath) + { + if (!string.IsNullOrWhiteSpace(outputJsonPath)) + { + return Path.GetFullPath(outputJsonPath); + } + + string inputDirectory = Path.GetDirectoryName(resolvedInputPath) ?? Environment.CurrentDirectory; + string inputFileName = Path.GetFileNameWithoutExtension(resolvedInputPath); + + if (string.IsNullOrWhiteSpace(inputFileName)) + { + inputFileName = "TrackLayoutPackageV2"; + } + + return Path.GetFullPath(Path.Combine(inputDirectory, inputFileName + DefaultOutputExtension)); + } + + private static string ResolveSourceFixtureName(string inputJsonPath) + { + string fileName = Path.GetFileName(inputJsonPath); + return string.IsNullOrWhiteSpace(fileName) ? inputJsonPath : fileName; + } + + private static bool IsCompilationOrEvaluationException(Exception ex) + { + return ex is ArgumentException || + ex is ArgumentOutOfRangeException || + ex is InvalidOperationException || + ex is NotSupportedException; + } + + private static bool IsReadOrWriteException(Exception ex) + { + return ex is IOException || + ex is UnauthorizedAccessException || + ex is ArgumentException || + ex is NotSupportedException; + } + } +} diff --git a/Quantum.Debug/Program.cs b/Quantum.Debug/Program.cs index 5ff9d36..06104e5 100644 --- a/Quantum.Debug/Program.cs +++ b/Quantum.Debug/Program.cs @@ -86,6 +86,24 @@ static int Main(string[] args) return DebugViewportSnapshotV1FromCsvCommand.Run(inputCsvPath, outputJsonPath); } + if (command == DebugCommandKind.DebugViewportSnapshotV1FromTrackLayoutPackageV2) + { + if (args.Length < 2 || args.Length > 3) + { + Console.WriteLine( + "Usage: " + + DebugViewportSnapshotV1FromTrackLayoutPackageV2Command.CommandName + + " [outputJsonPath]"); + return 1; + } + + string inputJsonPath = args[1]; + string? outputJsonPath = args.Length == 3 ? args[2] : null; + return DebugViewportSnapshotV1FromTrackLayoutPackageV2Command.Run( + inputJsonPath, + outputJsonPath); + } + if (command == DebugCommandKind.DebugViewportSnapshotV1Validate) { if (args.Length != 2) diff --git a/Quantum.IO/DebugViewport/V1/DebugViewportSnapshotV1Mapper.cs b/Quantum.IO/DebugViewport/V1/DebugViewportSnapshotV1Mapper.cs index 04a30d6..f428107 100644 --- a/Quantum.IO/DebugViewport/V1/DebugViewportSnapshotV1Mapper.cs +++ b/Quantum.IO/DebugViewport/V1/DebugViewportSnapshotV1Mapper.cs @@ -233,6 +233,8 @@ private static string MapAxisType(TrackFrameAxisType axisType) return DebugViewportSnapshotV1Vocabulary.FrameAxisNormalKind; case TrackFrameAxisType.Binormal: return DebugViewportSnapshotV1Vocabulary.FrameAxisBinormalKind; + case TrackFrameAxisType.Diagnostic: + return DebugViewportSnapshotV1Vocabulary.DiagnosticLineKind; default: return DebugViewportSnapshotV1Vocabulary.DiagnosticLineKind; } diff --git a/Quantum.Tests/Debug/DebugCommandHelpTests.cs b/Quantum.Tests/Debug/DebugCommandHelpTests.cs index 0ce913d..64d222c 100644 --- a/Quantum.Tests/Debug/DebugCommandHelpTests.cs +++ b/Quantum.Tests/Debug/DebugCommandHelpTests.cs @@ -24,6 +24,7 @@ public void TryWriteRequestedHelp_TopLevelHelpTokens_PrintGeneralHelp(string tok Assert.Contains("Commands:", output); Assert.Contains("mesh-export-v1-sample [outputPath]", output); Assert.Contains("debug-viewport-snapshot-v1-from-csv [outputJsonPath]", output); + Assert.Contains("debug-viewport-snapshot-v1-from-track-layout-package-v2 [outputJsonPath]", output); Assert.Contains("debug-viewport-snapshot-v1-gallery [artifactDirectory] [outputHtmlPath]", output); Assert.Contains("debug-viewport-snapshot-v1-browser [artifactDirectory] [outputHtmlPath]", output); Assert.Contains("debug-viewport-snapshot-v1-transition-authoring [outputPath]", output); @@ -128,6 +129,27 @@ public void TryWriteRequestedHelp_DebugViewportBrowser_PrintsBrowserViewerDetail Assert.Contains("not a final editor, frontend, renderer, or JSON contract change", output); } + [Fact] + public void TryWriteRequestedHelp_DebugViewportFromTrackLayoutPackageV2_PrintsImportExportDetails() + { + var writer = new StringWriter(CultureInfo.InvariantCulture); + + bool handled = DebugCommandHelp.TryWriteRequestedHelp( + new[] { "help", "debug-viewport-snapshot-v1-from-track-layout-package-v2" }, + writer, + out int exitCode); + + Assert.True(handled); + Assert.Equal(0, exitCode); + + string output = writer.ToString(); + Assert.Contains("debug-viewport-snapshot-v1-from-track-layout-package-v2 [outputJsonPath]", output); + Assert.Contains("TrackLayoutPackageV2 importer", output); + Assert.Contains("simple train boxes", output); + Assert.Contains("BankingProfile sampling path", output); + Assert.Contains("diagnostic connector lines", output); + } + [Fact] public void TryWriteRequestedHelp_TrainPoseExportV1_PrintsRegressionSampleDetails() { @@ -449,6 +471,7 @@ public void WriteUnknownCommand_PrintsUnknownCommandAndSupportedCommands() Assert.Contains("Supported commands:", output); Assert.Contains("mesh-export-v1-sample [outputPath]", output); Assert.Contains("debug-viewport-snapshot-v1 [outputPath]", output); + Assert.Contains("debug-viewport-snapshot-v1-from-track-layout-package-v2 [outputJsonPath]", output); Assert.Contains("debug-viewport-snapshot-v1-gallery [artifactDirectory] [outputHtmlPath]", output); Assert.Contains("debug-viewport-snapshot-v1-browser [artifactDirectory] [outputHtmlPath]", output); Assert.Contains("debug-viewport-snapshot-v1-spatial-layout [outputPath]", output); diff --git a/Quantum.Tests/Debug/DebugCommandParserTests.cs b/Quantum.Tests/Debug/DebugCommandParserTests.cs index f894859..44de988 100644 --- a/Quantum.Tests/Debug/DebugCommandParserTests.cs +++ b/Quantum.Tests/Debug/DebugCommandParserTests.cs @@ -70,6 +70,17 @@ public void TryParse_DebugViewportSnapshotV1FromCsvCommand_ParsesCaseInsensitive Assert.Equal(DebugCommandKind.DebugViewportSnapshotV1FromCsv, command); } + [Fact] + public void TryParse_DebugViewportSnapshotV1FromTrackLayoutPackageV2Command_ParsesCaseInsensitive() + { + bool parsed = DebugCommandParser.TryParse( + new[] { "DeBuG-ViEwPoRt-SnApShOt-V1-FrOm-TrAcK-LaYoUt-PaCkAgE-V2" }, + out DebugCommandKind command); + + Assert.True(parsed); + Assert.Equal(DebugCommandKind.DebugViewportSnapshotV1FromTrackLayoutPackageV2, command); + } + [Fact] public void TryParse_DebugViewportSnapshotV1ValidateCommand_ParsesCaseInsensitive() { diff --git a/Quantum.Tests/Debug/DebugViewportSnapshotV1FromTrackLayoutPackageV2CommandTests.cs b/Quantum.Tests/Debug/DebugViewportSnapshotV1FromTrackLayoutPackageV2CommandTests.cs new file mode 100644 index 0000000..95e2c6d --- /dev/null +++ b/Quantum.Tests/Debug/DebugViewportSnapshotV1FromTrackLayoutPackageV2CommandTests.cs @@ -0,0 +1,370 @@ +using System; +using System.Globalization; +using System.IO; +using System.Linq; +using Quantum.Debug; +using Quantum.IO.DebugViewport.V1; +using Quantum.IO.TrackLayout.V2; +using Quantum.Math; +using Quantum.Splines; +using Quantum.Track; + +namespace Quantum.Tests; + +public sealed class DebugViewportSnapshotV1FromTrackLayoutPackageV2CommandTests +{ + private const double Tolerance = 1e-6; + + [Fact] + public void Export_MinimalValidV2Package_ProducesValidSnapshot() + { + DebugViewportSnapshotV1FromTrackLayoutPackageV2ExportResult result = + DebugViewportSnapshotV1FromTrackLayoutPackageV2Command.Export(CreateMinimalDto()); + + Assert.True(result.Success, FormatDiagnostics(result)); + Assert.NotNull(result.Snapshot); + DebugViewportSnapshotV1Dto snapshot = result.Snapshot!; + AssertValidSnapshot(snapshot); + + Assert.Equal(DebugViewportSnapshotV1Dto.ContractName, snapshot.Contract); + Assert.Equal(DebugViewportSnapshotV1Dto.ContractVersion, snapshot.Version); + Assert.Equal("meters", snapshot.Metadata.Units); + Assert.Equal("Minimal V2 layout", snapshot.Metadata.SourceFixtureName); + Assert.Equal(5, snapshot.Metadata.SampleCount); + Assert.Equal(5, snapshot.CenterlinePoints.Length); + Assert.Equal(5, snapshot.Frames.Length); + Assert.Equal(3, snapshot.Lines.Length); + Assert.Equal(2, snapshot.Boxes.Length); + Assert.NotNull(snapshot.TrainPose); + Assert.Equal(2, snapshot.TrainPose!.Cars.Length); + Assert.Equal(DebugViewportSnapshotV1Vocabulary.TrainBodyRole, snapshot.Boxes[0].Role); + AssertDoubleNear(0.0, snapshot.CenterlinePoints[0].Distance); + AssertDoubleNear(12.0, snapshot.CenterlinePoints[^1].Distance); + AssertDoubleNear(12.0, snapshot.CenterlinePoints[^1].Position.X); + } + + [Fact] + public void Run_WithExplicitOutputPath_WritesSnapshotFromV2Json() + { + string tempDirectory = CreateTempDirectoryPath(); + string inputPath = Path.Combine(tempDirectory, "minimal-v2.json"); + string outputPath = Path.Combine(tempDirectory, "minimal-v2.snapshot.json"); + var writer = new StringWriter(CultureInfo.InvariantCulture); + + try + { + Directory.CreateDirectory(tempDirectory); + File.WriteAllText(inputPath, TrackLayoutPackageV2Json.Serialize(CreateMinimalDto(), indented: true)); + + int exitCode = DebugViewportSnapshotV1FromTrackLayoutPackageV2Command.Run( + inputPath, + outputPath, + writer); + + Assert.Equal(0, exitCode); + Assert.True(File.Exists(outputPath)); + DebugViewportSnapshotV1Dto snapshot = + DebugViewportSnapshotV1Json.Deserialize(File.ReadAllText(outputPath)); + AssertValidSnapshot(snapshot); + Assert.Contains("Wrote TrackLayoutPackageV2 DebugViewportSnapshotV1 snapshot", writer.ToString()); + } + finally + { + DeleteDirectoryIfPresent(tempDirectory); + } + } + + [Fact] + public void Export_RepresentativeSectionKinds_AppearInSampledOutput() + { + DebugViewportSnapshotV1FromTrackLayoutPackageV2ExportResult result = + DebugViewportSnapshotV1FromTrackLayoutPackageV2Command.Export(CreateRepresentativeSectionsDto()); + + Assert.True(result.Success, FormatDiagnostics(result)); + Assert.NotNull(result.Snapshot); + DebugViewportSnapshotV1Dto snapshot = result.Snapshot!; + AssertValidSnapshot(snapshot); + + Assert.Contains(snapshot.CenterlinePoints, point => IsNear(point.Distance, 6.0)); + Assert.Contains(snapshot.CenterlinePoints, point => IsNear(point.Distance, 18.0)); + Assert.Contains(snapshot.CenterlinePoints, point => IsNear(point.Distance, 24.0)); + Assert.Contains(snapshot.CenterlinePoints, point => IsNear(point.Distance, 33.0)); + Assert.True(snapshot.CenterlinePoints.Max(point => point.Position.Y) > 0.25); + Assert.True(snapshot.CenterlinePoints.Max(point => System.Math.Abs(point.Position.Z)) > 0.25); + Assert.Equal(3, snapshot.Lines.Length); + Assert.NotNull(snapshot.TrainPose); + } + + [Fact] + public void Export_BankedHeartline_UsesProfileBackedBoxesAndDiagnosticLines() + { + TrackLayoutPackageV2Dto dto = CreateMinimalDto(); + dto.Metadata.SourceName = "Banked heartline V2 snapshot"; + dto.Banking = new TrackBankingV2Dto + { + Keys = new[] + { + new TrackBankingKeyV2Dto + { + Distance = 0.0, + RollRadians = System.Math.PI * 0.5, + InterpolationToNext = TrackLayoutPackageV2Vocabulary.BankingInterpolationConstant + }, + new TrackBankingKeyV2Dto + { + Distance = 12.0, + RollRadians = System.Math.PI * 0.5, + InterpolationToNext = TrackLayoutPackageV2Vocabulary.BankingInterpolationConstant + } + } + }; + dto.Heartline = new TrackHeartlineV2Dto + { + Kind = TrackLayoutPackageV2Vocabulary.HeartlineKindConstantOffset, + DistanceDomain = TrackLayoutPackageV2Vocabulary.HeartlineDistanceDomainCenterlineStation, + AxisSource = TrackLayoutPackageV2Vocabulary.HeartlineAxisSourceSampledFrame, + NormalOffset = 1.0, + LateralOffset = 0.0 + }; + + DebugViewportSnapshotV1FromTrackLayoutPackageV2ExportResult result = + DebugViewportSnapshotV1FromTrackLayoutPackageV2Command.Export(dto); + + Assert.True(result.Success, FormatDiagnostics(result)); + Assert.NotNull(result.Snapshot); + DebugViewportSnapshotV1Dto snapshot = result.Snapshot!; + AssertValidSnapshot(snapshot); + + Assert.Contains(snapshot.Lines, line => line.Kind == DebugViewportSnapshotV1Vocabulary.DiagnosticLineKind); + Assert.All(snapshot.Boxes, box => Assert.Equal( + DebugViewportSnapshotV1Vocabulary.TrainBodyBankingProfileRole, + box.Role)); + } + + [Fact] + public void Export_InvalidV2Package_ReturnsValidationDiagnostics() + { + TrackLayoutPackageV2Dto dto = CreateMinimalDto(); + dto.Sections = Array.Empty(); + + DebugViewportSnapshotV1FromTrackLayoutPackageV2ExportResult result = + DebugViewportSnapshotV1FromTrackLayoutPackageV2Command.Export(dto); + + Assert.False(result.Success); + Assert.Null(result.Snapshot); + Assert.Contains(result.Diagnostics, diagnostic => + diagnostic.Code == "TrackLayoutPackageV2.EmptySections" && + diagnostic.Path == "sections"); + } + + [Fact] + public void Export_ValidV2PackageWithUnsupportedRuntimeGeometry_FailsPredictably() + { + TrackLayoutPackageV2Dto dto = CreateMinimalDto(); + dto.Metadata.SourceName = "Declared spatial length mismatch"; + dto.Sections = new[] + { + new TrackLayoutSectionV2Dto + { + Kind = TrackLayoutPackageV2Vocabulary.SpatialSectionKind, + Id = "spatial-length-mismatch", + Length = 6.0, + RollRadians = 0.0, + Degree = 3, + ControlPoints = new[] + { + Vector(0.0, 0.0, 0.0), + Vector(1.0, 0.0, 0.0), + Vector(2.0, 0.0, 0.0), + Vector(3.0, 0.0, 0.0) + }, + Weights = new[] { 1.0, 1.0, 1.0, 1.0 } + } + }; + + Assert.Empty(TrackLayoutPackageV2Validator.Validate(dto)); + + DebugViewportSnapshotV1FromTrackLayoutPackageV2ExportResult result = + DebugViewportSnapshotV1FromTrackLayoutPackageV2Command.Export(dto); + + Assert.False(result.Success); + Assert.Null(result.Snapshot); + DebugViewportSnapshotV1FromTrackLayoutPackageV2ExportDiagnostic diagnostic = + Assert.Single(result.Diagnostics); + Assert.Equal("TrackLayoutPackageV2.CompilationFailed", diagnostic.Code); + Assert.Equal("track", diagnostic.Path); + Assert.Contains("does not match measured geometric length", diagnostic.Message); + } + + 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 CreateRepresentativeSectionsDto() + { + const double spatialLength = 9.0; + TrackLayoutVector3dV2Dto[] spatialControlPoints = CreateNormalizedSpatialControlPoints( + spatialLength); + + return new TrackLayoutPackageV2Dto + { + Metadata = new TrackLayoutMetadataV2Dto + { + Units = "meters", + SourceName = "Representative V2 section snapshot", + LayoutId = "layout.m148.pr2.representative" + }, + Sections = new[] + { + new TrackLayoutSectionV2Dto + { + Kind = TrackLayoutPackageV2Vocabulary.StraightSectionKind, + Id = "entry", + Length = 6.0, + RollRadians = 0.0 + }, + new TrackLayoutSectionV2Dto + { + Kind = TrackLayoutPackageV2Vocabulary.ConstantCurvatureSectionKind, + Id = "arc", + Length = 12.0, + RollRadians = 0.0, + Radius = 20.0 + }, + new TrackLayoutSectionV2Dto + { + Kind = TrackLayoutPackageV2Vocabulary.CurvatureTransitionSectionKind, + Id = "transition", + Length = 6.0, + RollRadians = 0.0, + StartCurvature = 1.0 / 20.0, + EndCurvature = 0.0, + InterpolationMode = TrackLayoutPackageV2Vocabulary.CurvatureInterpolationLinear + }, + new TrackLayoutSectionV2Dto + { + Kind = TrackLayoutPackageV2Vocabulary.SpatialSectionKind, + Id = "spatial", + Length = spatialLength, + RollRadians = 0.0, + Degree = 3, + ControlPoints = spatialControlPoints, + Weights = Enumerable.Repeat(1.0, spatialControlPoints.Length).ToArray() + } + }, + Banking = null, + Heartline = null + }; + } + + private static TrackLayoutVector3dV2Dto[] CreateNormalizedSpatialControlPoints(double targetLength) + { + Vector3d[] points = + { + Vector3d.Zero, + new Vector3d(2.0, 0.0, 0.0), + new Vector3d(4.0, 0.0, 0.0), + new Vector3d(6.0, 1.5, 1.2), + new Vector3d(8.0, 2.0, 3.0) + }; + + for (int i = 0; i < 3; i++) + { + double measuredLength = MeasureSpatialLength(points); + double scale = targetLength / measuredLength; + points = points.Select(point => point * scale).ToArray(); + } + + return points.Select(point => Vector(point.X, point.Y, point.Z)).ToArray(); + } + + private static double MeasureSpatialLength(Vector3d[] points) + { + var curve = new GSharkNurbsCurveAdapter( + points.ToList(), + Enumerable.Repeat(1.0, points.Length).ToList(), + degree: 3); + return new ArcLengthLUT( + curve, + TrackSamplingOptions.DefaultArcLengthSamples, + TrackSamplingOptions.DefaultArcLengthTolerance).TotalLength; + } + + private static TrackLayoutVector3dV2Dto Vector(double x, double y, double z) + { + return new TrackLayoutVector3dV2Dto + { + X = x, + Y = y, + Z = z + }; + } + + private static void AssertValidSnapshot(DebugViewportSnapshotV1Dto snapshot) + { + bool isValid = DebugViewportSnapshotV1Validator.TryValidate( + snapshot, + out IReadOnlyList diagnostics); + + Assert.True( + isValid, + string.Join(Environment.NewLine, diagnostics.Select(d => $"{d.Code} {d.Path}: {d.Message}"))); + Assert.Empty(diagnostics); + } + + private static bool IsNear(double actual, double expected) + { + return System.Math.Abs(actual - expected) <= Tolerance; + } + + private static void AssertDoubleNear(double expected, double actual) + { + Assert.InRange(System.Math.Abs(expected - actual), 0.0, Tolerance); + } + + private static string FormatDiagnostics( + DebugViewportSnapshotV1FromTrackLayoutPackageV2ExportResult result) + { + return string.Join( + Environment.NewLine, + result.Diagnostics.Select(diagnostic => + diagnostic.Code + " " + diagnostic.Path + ": " + diagnostic.Message)); + } + + private static string CreateTempDirectoryPath() + { + return Path.Combine( + Path.GetTempPath(), + "QuantumCoasterWorks.DebugViewportSnapshotV1FromTrackLayoutPackageV2CommandTests", + Guid.NewGuid().ToString("N")); + } + + private static void DeleteDirectoryIfPresent(string path) + { + if (Directory.Exists(path)) + { + Directory.Delete(path, recursive: true); + } + } +} diff --git a/Quantum.Track/TrackFrameAxisType.cs b/Quantum.Track/TrackFrameAxisType.cs index 02646cf..a355075 100644 --- a/Quantum.Track/TrackFrameAxisType.cs +++ b/Quantum.Track/TrackFrameAxisType.cs @@ -4,6 +4,7 @@ public enum TrackFrameAxisType { Tangent = 0, Normal = 1, - Binormal = 2 + Binormal = 2, + Diagnostic = 3 } } diff --git a/README.md b/README.md index 79fda99..3b798a3 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,12 @@ Generate a snapshot from a self-authored sampled-frame CSV fixture: dotnet run --project Quantum.Debug -- debug-viewport-snapshot-v1-from-csv Quantum.Tests/IO/Fixtures/Milestone7.synthetic.straight_line.centerline_frames.csv artifacts/debug-viewport/Milestone7.synthetic.straight_line.snapshot.json ``` +Generate a snapshot from a `TrackLayoutPackageV2` JSON layout: + +```powershell +dotnet run --project Quantum.Debug -- debug-viewport-snapshot-v1-from-track-layout-package-v2 artifacts/layouts/minimal-v2.json artifacts/debug-viewport/minimal-v2.snapshot.json +``` + Validate and inspect a snapshot JSON file: ```powershell @@ -198,6 +204,7 @@ Generated JSON, SVG, Markdown, and HTML under `artifacts/` are local output by d - `dotnet run --project Quantum.Debug -- debug-viewport-snapshot-v1-transition-authoring [outputPath]`: write the deterministic transition-authoring `DebugViewportSnapshotV1` sample JSON. - `dotnet run --project Quantum.Debug -- debug-viewport-snapshot-v1-spatial-layout [outputPath]`: write the deterministic three-dimensional spatial-layout `DebugViewportSnapshotV1` sample JSON. - `dotnet run --project Quantum.Debug -- debug-viewport-snapshot-v1-from-csv [outputJsonPath]`: bridge a sampled-frame CSV fixture to `DebugViewportSnapshotV1` JSON. +- `dotnet run --project Quantum.Debug -- debug-viewport-snapshot-v1-from-track-layout-package-v2 [outputJsonPath]`: import `TrackLayoutPackageV2`, compile/evaluate it through backend authoring/runtime systems, and export validated `DebugViewportSnapshotV1` JSON. - `dotnet run --project Quantum.Debug -- debug-viewport-snapshot-v1-validate `: validate and summarize a snapshot JSON file. - `dotnet run --project Quantum.Debug -- debug-viewport-snapshot-v1-svg [outputSvgPath]`: write a multi-panel backend-only SVG preview from snapshot JSON. - `dotnet run --project Quantum.Debug -- debug-viewport-snapshot-v1-gallery [artifactDirectory] [outputHtmlPath]`: write a static HTML gallery for generated DebugViewportSnapshotV1 JSON and SVG artifacts.