From 1b5e1e8ee5664e1abbeba662134a04dc56d190a4 Mon Sep 17 00:00:00 2001 From: Coasterpete <83011124+Coasterpete@users.noreply.github.com> Date: Fri, 26 Jun 2026 04:31:11 -0400 Subject: [PATCH] track: polish runtime sampling defaults --- .../BankingProfileTrainPoseFixtures.cs | 11 +- .../ProfileBankedHeartlineProofScenario.cs | 5 +- Quantum.Debug/SpatialLayoutProofScenario.cs | 5 +- ...tV1FromTrackLayoutPackageV2CommandTests.cs | 5 +- .../IO/TrackLayoutPackageV1ProofFixtures.cs | 4 +- .../Track/CanonicalTransportedFrameTests.cs | 145 ++++++++++++++++++ .../SpatialSectionTrackAuthoringTests.cs | 5 +- ...oringGeometryContinuityDiagnosticsTests.cs | 5 +- .../Track/TrainRuntimeIntegrationTests.cs | 19 +++ .../TrackAuthoringDocumentBuilder.cs | 31 ++-- .../Internal/CompiledTrackSamplingContext.cs | 88 +---------- Quantum.Track/TrackEvaluator.cs | 10 ++ Quantum.Track/TrainCarTransformProvider.cs | 25 ++- ...ame-diagnostics-architecture-checkpoint.md | 54 ++++--- docs/design/transported-frame-sampler.md | 130 ++++++++-------- 15 files changed, 333 insertions(+), 209 deletions(-) diff --git a/Quantum.Debug/BankingProfileTrainPoseFixtures.cs b/Quantum.Debug/BankingProfileTrainPoseFixtures.cs index cfdbd8c..5727a54 100644 --- a/Quantum.Debug/BankingProfileTrainPoseFixtures.cs +++ b/Quantum.Debug/BankingProfileTrainPoseFixtures.cs @@ -12,6 +12,9 @@ namespace Quantum.Debug { public sealed class BankingProfileTrainPoseFixture { + private readonly TrackEvaluator _runtimeEvaluator; + private readonly TrainCarTransformProvider _runtimeProvider; + public BankingProfileTrainPoseFixture( string name, TrackDocument document, @@ -42,6 +45,8 @@ public BankingProfileTrainPoseFixture( Definition = definition ?? throw new ArgumentNullException(nameof(definition)); LeadDistance = leadDistance; CenterlineSampleDistances = Array.AsReadOnly(sampleDistances); + _runtimeEvaluator = new TrackEvaluator(new CompiledTrackRuntime(Document)); + _runtimeProvider = new TrainCarTransformProvider(_runtimeEvaluator); } public string Name { get; } @@ -58,9 +63,7 @@ public BankingProfileTrainPoseFixture( public TrainPoseResult EvaluateTrainPose() { - var evaluator = new TrackEvaluator(Document); - var provider = new TrainCarTransformProvider(evaluator); - return provider.EvaluateTrainPose( + return _runtimeProvider.EvaluateTrainPose( LeadDistance, Definition, BankingProfile); @@ -74,7 +77,7 @@ public TrainPoseExportV1Dto ExportTrainPose() public DebugViewportSnapshotV1Dto BuildDebugViewportSnapshot() { ExportTrackFrame[] sampledFrames = BankingProfileSampler.SampleFramesAtDistances( - Document, + _runtimeEvaluator, BankingProfile, CenterlineSampleDistances); TrainPoseResult trainPose = EvaluateTrainPose(); diff --git a/Quantum.Debug/ProfileBankedHeartlineProofScenario.cs b/Quantum.Debug/ProfileBankedHeartlineProofScenario.cs index 215f987..7848efc 100644 --- a/Quantum.Debug/ProfileBankedHeartlineProofScenario.cs +++ b/Quantum.Debug/ProfileBankedHeartlineProofScenario.cs @@ -215,10 +215,11 @@ private static double MeasureLength(IReadOnlyList controlPoints) var points = controlPoints.ToList(); var weights = Enumerable.Repeat(1.0, points.Count).ToList(); var curve = new GSharkNurbsCurveAdapter(points, weights, SpatialDegree); + TrackSamplingOptions samplingOptions = TrackSamplingOptions.Default; return new ArcLengthLUT( curve, - TrackSamplingOptions.DefaultArcLengthSamples, - TrackSamplingOptions.DefaultArcLengthTolerance).TotalLength; + samplingOptions.ArcLengthSamples, + samplingOptions.ArcLengthTolerance).TotalLength; } private static double[] BuildSampleDistances() diff --git a/Quantum.Debug/SpatialLayoutProofScenario.cs b/Quantum.Debug/SpatialLayoutProofScenario.cs index 791d67b..7e46e2d 100644 --- a/Quantum.Debug/SpatialLayoutProofScenario.cs +++ b/Quantum.Debug/SpatialLayoutProofScenario.cs @@ -177,10 +177,11 @@ private static double MeasureLength(IReadOnlyList controlPoints) var points = controlPoints.ToList(); var weights = Enumerable.Repeat(1.0, points.Count).ToList(); var curve = new GSharkNurbsCurveAdapter(points, weights, SpatialDegree); + TrackSamplingOptions samplingOptions = TrackSamplingOptions.Default; return new ArcLengthLUT( curve, - TrackSamplingOptions.DefaultArcLengthSamples, - TrackSamplingOptions.DefaultArcLengthTolerance).TotalLength; + samplingOptions.ArcLengthSamples, + samplingOptions.ArcLengthTolerance).TotalLength; } private static double[] BuildFrameDistances() diff --git a/Quantum.Tests/Debug/DebugViewportSnapshotV1FromTrackLayoutPackageV2CommandTests.cs b/Quantum.Tests/Debug/DebugViewportSnapshotV1FromTrackLayoutPackageV2CommandTests.cs index 95e2c6d..7c79561 100644 --- a/Quantum.Tests/Debug/DebugViewportSnapshotV1FromTrackLayoutPackageV2CommandTests.cs +++ b/Quantum.Tests/Debug/DebugViewportSnapshotV1FromTrackLayoutPackageV2CommandTests.cs @@ -305,10 +305,11 @@ private static double MeasureSpatialLength(Vector3d[] points) points.ToList(), Enumerable.Repeat(1.0, points.Length).ToList(), degree: 3); + TrackSamplingOptions samplingOptions = TrackSamplingOptions.Default; return new ArcLengthLUT( curve, - TrackSamplingOptions.DefaultArcLengthSamples, - TrackSamplingOptions.DefaultArcLengthTolerance).TotalLength; + samplingOptions.ArcLengthSamples, + samplingOptions.ArcLengthTolerance).TotalLength; } private static TrackLayoutVector3dV2Dto Vector(double x, double y, double z) diff --git a/Quantum.Tests/IO/TrackLayoutPackageV1ProofFixtures.cs b/Quantum.Tests/IO/TrackLayoutPackageV1ProofFixtures.cs index 9567c10..f5a3d39 100644 --- a/Quantum.Tests/IO/TrackLayoutPackageV1ProofFixtures.cs +++ b/Quantum.Tests/IO/TrackLayoutPackageV1ProofFixtures.cs @@ -262,8 +262,8 @@ private static double MeasureNurbsLength( new List(controlPoints), new List(weights), degree), - TrackSamplingOptions.DefaultArcLengthSamples, - TrackSamplingOptions.DefaultArcLengthTolerance); + TrackSamplingOptions.Default.ArcLengthSamples, + TrackSamplingOptions.Default.ArcLengthTolerance); return curve.Length; } diff --git a/Quantum.Tests/Track/CanonicalTransportedFrameTests.cs b/Quantum.Tests/Track/CanonicalTransportedFrameTests.cs index dd4d7b4..001b9a0 100644 --- a/Quantum.Tests/Track/CanonicalTransportedFrameTests.cs +++ b/Quantum.Tests/Track/CanonicalTransportedFrameTests.cs @@ -218,6 +218,59 @@ public void TrainBodiesAndBogies_MatchCanonicalFramesAtTheirStations() } } + [Fact] + public void DefaultTransportDensity_ImplicitAndExplicitRuntimeOptionsMatch() + { + TrackDocument document = CreateThreeDimensionalDensityDocument(); + double[] distances = BuildUniformDistances(document.TotalLength, sampleCount: 21); + + ExportTrackFrame[] implicitDefault = new TrackEvaluator( + new CompiledTrackRuntime(document)) + .EvaluateFramesAtDistances(distances); + ExportTrackFrame[] explicitDefault = new TrackEvaluator( + new CompiledTrackRuntime(document, TrackSamplingOptions.Default)) + .EvaluateFramesAtDistances(distances); + + for (int i = 0; i < distances.Length; i++) + { + AssertFrameNear(implicitDefault[i], explicitDefault[i]); + } + } + + [Fact] + public void TransportSampleDensity_PreservesCenterlineAndConvergesOrientation() + { + TrackDocument document = CreateThreeDimensionalDensityDocument(); + double[] distances = BuildUniformDistances(document.TotalLength, sampleCount: 31); + + ExportTrackFrame[] sparse = SampleWithTransportSamples( + document, + transportSamplesPerSegment: 2, + distances); + ExportTrackFrame[] medium = SampleWithTransportSamples( + document, + transportSamplesPerSegment: 16, + distances); + ExportTrackFrame[] dense = SampleWithTransportSamples( + document, + transportSamplesPerSegment: 128, + distances); + + AssertCenterlineSamplesNear(sparse, dense); + AssertCenterlineSamplesNear(medium, dense); + + double sparseToDense = MaxNormalOrBinormalAngleDeltaRadians(sparse, dense); + double mediumToDense = MaxNormalOrBinormalAngleDeltaRadians(medium, dense); + + Assert.True( + sparseToDense > 1e-4, + $"Expected sparse transport density to produce a measurable orientation delta, got {sparseToDense:R} radians."); + Assert.True( + mediumToDense < sparseToDense, + $"Expected denser transport sampling to converge: sparse={sparseToDense:R}, medium={mediumToDense:R}."); + Assert.InRange(mediumToDense, 0.0, 0.05); + } + private static TrackDocument CreateSmoothTwoSegmentDocument() { var first = new CubicBezierCurve( @@ -238,6 +291,98 @@ private static TrackDocument CreateSmoothTwoSegmentDocument() }); } + private static TrackDocument CreateThreeDimensionalDensityDocument() + { + var curve = new CubicBezierCurve( + new Vector3d(0.0, 0.0, 0.0), + new Vector3d(12.0, -4.0, 18.0), + new Vector3d(-6.0, 22.0, 30.0), + new Vector3d(28.0, 16.0, 44.0)); + + return new TrackDocument(new TrackSegment[] + { + new CurvedSegment( + new ArcLengthLUT(curve).TotalLength, + id: "transport-density-3d", + spline: curve) + }); + } + + private static double[] BuildUniformDistances(double totalLength, int sampleCount) + { + var distances = new double[sampleCount]; + for (int i = 0; i < distances.Length; i++) + { + distances[i] = totalLength * i / (sampleCount - 1.0); + } + + distances[distances.Length - 1] = totalLength; + return distances; + } + + private static ExportTrackFrame[] SampleWithTransportSamples( + TrackDocument document, + int transportSamplesPerSegment, + IReadOnlyList distances) + { + var options = new TrackSamplingOptions( + TrackSamplingOptions.Default.ArcLengthSamples, + TrackSamplingOptions.Default.ArcLengthTolerance, + transportSamplesPerSegment); + var evaluator = new TrackEvaluator(new CompiledTrackRuntime(document, options)); + return evaluator.EvaluateFramesAtDistances(distances); + } + + private static void AssertCenterlineSamplesNear( + IReadOnlyList expected, + IReadOnlyList actual) + { + Assert.Equal(expected.Count, actual.Count); + for (int i = 0; i < expected.Count; i++) + { + AssertNear(expected[i].Distance, actual[i].Distance); + AssertVectorNear(expected[i].Position, actual[i].Position); + AssertVectorNear(expected[i].Tangent, actual[i].Tangent); + } + } + + private static double MaxNormalOrBinormalAngleDeltaRadians( + IReadOnlyList expected, + IReadOnlyList actual) + { + Assert.Equal(expected.Count, actual.Count); + double max = 0.0; + + for (int i = 0; i < expected.Count; i++) + { + max = System.Math.Max(max, AngleDeltaRadians(expected[i].Normal, actual[i].Normal)); + max = System.Math.Max(max, AngleDeltaRadians(expected[i].Binormal, actual[i].Binormal)); + } + + return max; + } + + private static double AngleDeltaRadians(Vector3d expected, Vector3d actual) + { + double dot = Vector3d.Dot(expected.Normalized(), actual.Normalized()); + return System.Math.Acos(Clamp(dot, -1.0, 1.0)); + } + + private static double Clamp(double value, double min, double max) + { + if (value < min) + { + return min; + } + + if (value > max) + { + return max; + } + + return value; + } + private static void AssertValidFrame(ExportTrackFrame frame) { Assert.True(IsFinite(frame.Position)); diff --git a/Quantum.Tests/Track/SpatialSectionTrackAuthoringTests.cs b/Quantum.Tests/Track/SpatialSectionTrackAuthoringTests.cs index 545631c..da0e226 100644 --- a/Quantum.Tests/Track/SpatialSectionTrackAuthoringTests.cs +++ b/Quantum.Tests/Track/SpatialSectionTrackAuthoringTests.cs @@ -296,10 +296,11 @@ private static double MeasureLength( { weights ??= Enumerable.Repeat(1.0, controlPoints.Count).ToList(); var curve = new GSharkNurbsCurveAdapter(controlPoints, weights, degree); + TrackSamplingOptions samplingOptions = TrackSamplingOptions.Default; return new ArcLengthLUT( curve, - TrackSamplingOptions.DefaultArcLengthSamples, - TrackSamplingOptions.DefaultArcLengthTolerance).TotalLength; + samplingOptions.ArcLengthSamples, + samplingOptions.ArcLengthTolerance).TotalLength; } private static TrackStartPose CreateArbitraryStartPose() diff --git a/Quantum.Tests/Track/TrackAuthoringGeometryContinuityDiagnosticsTests.cs b/Quantum.Tests/Track/TrackAuthoringGeometryContinuityDiagnosticsTests.cs index 378551f..7367248 100644 --- a/Quantum.Tests/Track/TrackAuthoringGeometryContinuityDiagnosticsTests.cs +++ b/Quantum.Tests/Track/TrackAuthoringGeometryContinuityDiagnosticsTests.cs @@ -470,10 +470,11 @@ private static SpatialSectionDefinition CreateSpatial( var points = controlPoints.ToList(); var weights = Enumerable.Repeat(1.0, points.Count).ToList(); var curve = new GSharkNurbsCurveAdapter(points, weights, degree); + TrackSamplingOptions samplingOptions = TrackSamplingOptions.Default; double length = new ArcLengthLUT( curve, - TrackSamplingOptions.DefaultArcLengthSamples, - TrackSamplingOptions.DefaultArcLengthTolerance).TotalLength; + samplingOptions.ArcLengthSamples, + samplingOptions.ArcLengthTolerance).TotalLength; return new SpatialSectionDefinition(id, length, points, degree, weights); } diff --git a/Quantum.Tests/Track/TrainRuntimeIntegrationTests.cs b/Quantum.Tests/Track/TrainRuntimeIntegrationTests.cs index d71f6b0..55122d6 100644 --- a/Quantum.Tests/Track/TrainRuntimeIntegrationTests.cs +++ b/Quantum.Tests/Track/TrainRuntimeIntegrationTests.cs @@ -157,6 +157,25 @@ public void RepeatedRuntimePoseEvaluation_DoesNotRereadArcLengthCompilationMetad Assert.Equal(readsAfterCompilation, curve.LengthReadCount); } + [Fact] + public void DocumentProviderPoseEvaluation_CompilesOneRuntimeSnapshotPerCall() + { + var curve = new CountingArcLengthLineCurve(30.0); + var document = new TrackDocument(new[] + { + new StraightSegment(curve.Length, "counting", spline: curve) + }); + var provider = new TrainCarTransformProvider(new TrackEvaluator(document)); + TrainConsistDefinition definition = CreateConsistDefinition(); + int readsBeforePoseEvaluation = curve.LengthReadCount; + + provider.EvaluateTrainPose(24.0, definition); + Assert.Equal(readsBeforePoseEvaluation + 1, curve.LengthReadCount); + + provider.EvaluateTrainPose(24.0, definition); + Assert.Equal(readsBeforePoseEvaluation + 2, curve.LengthReadCount); + } + [Fact] public void InvalidTrainDistances_DocumentAndRuntimeProvidersThrowEquivalentExceptions() { diff --git a/Quantum.Track/Authoring/TrackAuthoringDocumentBuilder.cs b/Quantum.Track/Authoring/TrackAuthoringDocumentBuilder.cs index 8b6274c..8d0d1cb 100644 --- a/Quantum.Track/Authoring/TrackAuthoringDocumentBuilder.cs +++ b/Quantum.Track/Authoring/TrackAuthoringDocumentBuilder.cs @@ -29,6 +29,7 @@ public static TrackAuthoringCompilation Compile(TrackAuthoringDefinition definit throw new ArgumentNullException(nameof(definition)); } + TrackSamplingOptions samplingOptions = TrackSamplingOptions.Default; IReadOnlyList definitions = definition.Sections; var sectionLengths = new List<(GeometricSectionDefinition Section, double Length)>( definitions.Count); @@ -51,7 +52,8 @@ public static TrackAuthoringCompilation Compile(TrackAuthoringDefinition definit List segments = CreatePlacedSegments( definitions, geometricSections, - definition.StartPose); + definition.StartPose, + samplingOptions); var document = new TrackDocument( segments, @@ -64,7 +66,7 @@ public static TrackAuthoringCompilation Compile(TrackAuthoringDefinition definit totalLength); TrackRuntimeCompileResult runtimeCompilation = TrackRuntimeCompiler.Compile( document, - TrackSamplingOptions.Default); + samplingOptions); if (!runtimeCompilation.Success || runtimeCompilation.Runtime is null) { @@ -227,7 +229,8 @@ private static GeometricSection CreateGeometricSection( private static List CreatePlacedSegments( IReadOnlyList definitions, IReadOnlyList geometricSections, - TrackStartPose startPose) + TrackStartPose startPose, + TrackSamplingOptions samplingOptions) { var segments = new List(definitions.Count); Vector3d currentPosition = startPose.Position; @@ -237,7 +240,10 @@ private static List CreatePlacedSegments( for (int i = 0; i < definitions.Count; i++) { - IArcLengthCurve localCurve = CreateLocalCurve(definitions[i], geometricSections[i]); + IArcLengthCurve localCurve = CreateLocalCurve( + definitions[i], + geometricSections[i], + samplingOptions); var placedCurve = new PlacedAuthoringSectionCurve( localCurve, currentPosition, @@ -254,7 +260,8 @@ private static List CreatePlacedSegments( placedCurve, ref currentTangent, ref currentNormal, - ref currentBinormal); + ref currentBinormal, + samplingOptions); } else { @@ -273,7 +280,8 @@ private static List CreatePlacedSegments( private static IArcLengthCurve CreateLocalCurve( GeometricSectionDefinition definition, - GeometricSection geometricSection) + GeometricSection geometricSection, + TrackSamplingOptions samplingOptions) { if (definition is CurvatureTransitionSectionDefinition transition) { @@ -292,8 +300,8 @@ private static IArcLengthCurve CreateLocalCurve( spatial.Degree); return new ArcLengthCurveAdapter( curve, - TrackSamplingOptions.DefaultArcLengthSamples, - TrackSamplingOptions.DefaultArcLengthTolerance); + samplingOptions.ArcLengthSamples, + samplingOptions.ArcLengthTolerance); } IParamCurve generatedCurve = geometricSection.GenerateCurve(); @@ -338,16 +346,17 @@ private static void AdvanceSpatialConstructionBasis( IArcLengthCurve curve, ref Vector3d tangent, ref Vector3d normal, - ref Vector3d binormal) + ref Vector3d binormal, + TrackSamplingOptions samplingOptions) { Vector3d previousTangent = curve.TangentByLength(0.0); Vector3d transportedNormal = normal; double curveLength = curve.Length; - for (int i = 1; i <= TrackSamplingOptions.DefaultTransportSamplesPerSegment; i++) + for (int i = 1; i <= samplingOptions.TransportSamplesPerSegment; i++) { double fraction = - (double)i / TrackSamplingOptions.DefaultTransportSamplesPerSegment; + (double)i / samplingOptions.TransportSamplesPerSegment; Vector3d currentTangent = curve.TangentByLength(curveLength * fraction); transportedNormal = RotationMinimizingFrameTransport.TransportNormal( transportedNormal, diff --git a/Quantum.Track/Internal/CompiledTrackSamplingContext.cs b/Quantum.Track/Internal/CompiledTrackSamplingContext.cs index bde1350..e13a92d 100644 --- a/Quantum.Track/Internal/CompiledTrackSamplingContext.cs +++ b/Quantum.Track/Internal/CompiledTrackSamplingContext.cs @@ -7,8 +7,6 @@ namespace Quantum.Track.Internal { internal sealed class CompiledTrackSamplingContext { - private const int ArcLengthSamples = 100; - private const int TransportSamplesPerSegment = 100; private const double DeclaredLengthAbsoluteTolerance = 1e-3; private const double DeclaredLengthRelativeTolerance = 1e-6; @@ -40,68 +38,7 @@ private CompiledTrackSamplingContext( public static CompiledTrackSamplingContext Compile(TrackDocument document) { - if (document is null) - { - throw new ArgumentNullException(nameof(document)); - } - - int segmentCount = document.Segments.Count; - var segments = new CompiledTrackSegment[segmentCount]; - double totalLength = 0.0; - - for (int i = 0; i < segmentCount; i++) - { - TrackSegment segment = document.Segments[i]; - - if (segment is null) - { - throw new InvalidOperationException("TrackDocument contains a null segment entry."); - } - - ValidateFinitePositiveLength(segment, i); - ValidateFiniteRoll(segment, i); - - ArcLengthLUT? arcLengthLookup = null; - double geometricLength = segment.Length; - - if (segment.Spline is IParamCurve spline) - { - arcLengthLookup = BuildArcLengthLookup(spline, i); - ValidateMeasuredLength(arcLengthLookup.TotalLength, i); - if (segment.Spline is IArcLengthCurve arcLengthCurve) - { - geometricLength = arcLengthCurve.Length; - ValidateMeasuredLength(geometricLength, i); - ValidateLengthsMatch( - geometricLength, - arcLengthLookup.TotalLength, - i, - "reported arc length"); - } - else - { - geometricLength = arcLengthLookup.TotalLength; - } - - ValidateMeasuredLength(geometricLength, i); - ValidateDeclaredLengthMatchesMeasured(segment.Length, geometricLength, i); - ValidateSplineTangents(spline, i); - } - - segments[i] = new CompiledTrackSegment( - segment, - totalLength, - geometricLength, - arcLengthLookup, - arcLengthLookup?.TotalLength ?? geometricLength); - totalLength += geometricLength; - } - - return new CompiledTrackSamplingContext( - segments, - totalLength, - BuildTransportNodeDistances(segments, totalLength), - document.StartPose?.Normal); + return Compile(document, TrackSamplingOptions.Default); } public static CompiledTrackSamplingContext Compile( @@ -466,16 +403,6 @@ public ResolvedTrackDistance Resolve(double distance) throw new InvalidOperationException("TrackDocument could not be evaluated at the specified distance."); } - private static double[] BuildTransportNodeDistances( - IReadOnlyList segments, - double totalLength) - { - return BuildTransportNodeDistances( - segments, - totalLength, - TransportSamplesPerSegment); - } - private static double[] BuildTransportNodeDistances( IReadOnlyList segments, double totalLength, @@ -522,14 +449,6 @@ private static void ValidateFiniteRoll(TrackSegment segment, int segmentIndex) } } - private static ArcLengthLUT BuildArcLengthLookup(IParamCurve spline, int segmentIndex) - { - return BuildArcLengthLookup( - spline, - segmentIndex, - TrackSamplingOptions.Default); - } - private static ArcLengthLUT BuildArcLengthLookup( IParamCurve spline, int segmentIndex, @@ -585,11 +504,6 @@ private static void ValidateLengthsMatch( } } - private static void ValidateSplineTangents(IParamCurve spline, int segmentIndex) - { - ValidateSplineTangents(spline, segmentIndex, ArcLengthSamples); - } - private static void ValidateSplineTangents( IParamCurve spline, int segmentIndex, diff --git a/Quantum.Track/TrackEvaluator.cs b/Quantum.Track/TrackEvaluator.cs index 750fe95..a679c65 100644 --- a/Quantum.Track/TrackEvaluator.cs +++ b/Quantum.Track/TrackEvaluator.cs @@ -197,6 +197,16 @@ internal TrackDocument GetBoundTrackDocument() return ResolveBoundDocument(); } + internal TrackEvaluator CreateCurrentRuntimeSnapshotEvaluator() + { + if (_boundSamplingContext != null) + { + return this; + } + + return new TrackEvaluator(new CompiledTrackRuntime(ResolveBoundDocument())); + } + /// /// Explicit support-layer compatibility API for callers that still need /// the spline frame contract. diff --git a/Quantum.Track/TrainCarTransformProvider.cs b/Quantum.Track/TrainCarTransformProvider.cs index 5edba2a..a4043c0 100644 --- a/Quantum.Track/TrainCarTransformProvider.cs +++ b/Quantum.Track/TrainCarTransformProvider.cs @@ -17,7 +17,6 @@ namespace Quantum.Track public sealed class TrainCarTransformProvider { private readonly TrackEvaluator _evaluator; - private readonly TrainCarBodySampler _bodySampler; private readonly TrainArticulationFrameSolver _articulationSolver; private readonly TrainWheelTransformLayoutSolver _wheelLayoutSolver; private readonly TrainPoseAssembler _poseAssembler; @@ -25,7 +24,6 @@ public sealed class TrainCarTransformProvider public TrainCarTransformProvider(TrackEvaluator evaluator) { _evaluator = evaluator ?? throw new ArgumentNullException(nameof(evaluator)); - _bodySampler = new TrainCarBodySampler(_evaluator); _articulationSolver = new TrainArticulationFrameSolver(); _wheelLayoutSolver = new TrainWheelTransformLayoutSolver(); _poseAssembler = new TrainPoseAssembler(); @@ -84,7 +82,8 @@ public IReadOnlyList EvaluateCarTransforms( double carSpacing, int carCount) { - return _bodySampler.SampleBodies(leadDistance, carSpacing, carCount); + return new TrainCarBodySampler(CreateRuntimeSnapshotEvaluator()) + .SampleBodies(leadDistance, carSpacing, carCount); } /// @@ -291,7 +290,9 @@ public IReadOnlyList EvaluateTrainWithBogies( "Bogie spacing must be finite and non-negative."); } + TrackEvaluator evaluator = CreateRuntimeSnapshotEvaluator(); double totalLength = ResolveTotalLengthAndValidateBodyInputs( + evaluator, leadDistance, carSpacing, carCount); @@ -305,7 +306,7 @@ public IReadOnlyList EvaluateTrainWithBogies( bodyDistances, bogieSpacing, totalLength, - distances => _evaluator.EvaluateFramesAtDistances(distances)); + distances => evaluator.EvaluateFramesAtDistances(distances)); } private IReadOnlyList SampleProfileBackedBodies( @@ -314,7 +315,9 @@ private IReadOnlyList SampleProfileBackedBodies( int carCount, BankingProfile bankingProfile) { + TrackEvaluator evaluator = CreateRuntimeSnapshotEvaluator(); double totalLength = ResolveTotalLengthAndValidateBodyInputs( + evaluator, leadDistance, carSpacing, carCount); @@ -324,7 +327,7 @@ private IReadOnlyList SampleProfileBackedBodies( carCount, totalLength); TrackFrame[] frames = BankingProfileSampler.SampleFramesAtDistances( - _evaluator, + evaluator, bankingProfile, distances); var transforms = new List(carCount); @@ -348,7 +351,9 @@ private IReadOnlyList EvaluateProfileBac TrainWheelLayout wheelLayout, BankingProfile bankingProfile) { + TrackEvaluator evaluator = CreateRuntimeSnapshotEvaluator(); double totalLength = ResolveTotalLengthAndValidateBodyInputs( + evaluator, leadDistance, definition.CarSpacing, definition.CarCount); @@ -362,7 +367,7 @@ private IReadOnlyList EvaluateProfileBac definition.BogieSpacing, totalLength, distances => BankingProfileSampler.SampleFramesAtDistances( - _evaluator, + evaluator, bankingProfile, distances)); @@ -443,6 +448,7 @@ private static IReadOnlyList SampleTrainWithBogies( } private double ResolveTotalLengthAndValidateBodyInputs( + TrackEvaluator evaluator, double leadDistance, double carSpacing, int carCount) @@ -471,7 +477,7 @@ private double ResolveTotalLengthAndValidateBodyInputs( "Car count must be non-negative."); } - double totalLength = _evaluator.GetBoundTrackTotalLength(); + double totalLength = evaluator.GetBoundTrackTotalLength(); ValidateDistanceInRange( leadDistance, totalLength, @@ -479,6 +485,11 @@ private double ResolveTotalLengthAndValidateBodyInputs( return totalLength; } + private TrackEvaluator CreateRuntimeSnapshotEvaluator() + { + return _evaluator.CreateCurrentRuntimeSnapshotEvaluator(); + } + private static double[] BuildBodyDistances( double leadDistance, double carSpacing, diff --git a/docs/design/frame-diagnostics-architecture-checkpoint.md b/docs/design/frame-diagnostics-architecture-checkpoint.md index 50e1f05..54bdbf6 100644 --- a/docs/design/frame-diagnostics-architecture-checkpoint.md +++ b/docs/design/frame-diagnostics-architecture-checkpoint.md @@ -7,6 +7,14 @@ diagnostics foundation established across Milestones 39-46 before starting This checkpoint does not implement runtime behavior, change production contracts, add dependencies, or add Unity/browser/frontend code. +Milestone 151 note: this is a historical checkpoint. Several "current" runtime +statements below describe the Milestone 47 state and are superseded by later +work: `TrackEvaluator` now uses canonical transported-frame history for scalar +and batch coaster frames, `TransportedTrackFrameSampler` is an obsolete +compatibility facade, and `BankingProfile` runtime sampling exists as an +explicit opt-in path. The diagnostic/export rationale remains useful, but use +`docs/public-coaster-api-boundary.md` for current runtime behavior. + ## Context Milestone 39 established the external coaster simulation reference audit. The @@ -54,8 +62,8 @@ behavior or banking behavior. ### Transported frame sampler -`TransportedTrackFrameSampler` now exists as an explicit station-distance -sampler. It: +Historical Milestone 47 state: `TransportedTrackFrameSampler` existed as an +explicit station-distance sampler. It: - requires finite distances in non-decreasing station order - preserves duplicate sample distances and output order @@ -65,14 +73,15 @@ sampler. It: - applies existing segment `RollRadians` only after base-frame transport - returns the existing `Quantum.Track.TrackFrame` contract -The sampler is not hidden inside `TrackEvaluator`. It is opt-in and sequence -based, which keeps transported behavior reviewable and prevents scalar frame -evaluation from becoming history-dependent. +Current runtime state: canonical transported-frame sampling now lives inside +compiled `TrackEvaluator` sampling state. `TransportedTrackFrameSampler` remains +only as an obsolete facade over the canonical evaluator path. ### Stateless-vs-transported comparison diagnostics -`TransportedFrameComparisonDiagnostics` compares the current stateless evaluator -frame sequence with the transported sampler over the same distances. The report +`TransportedFrameComparisonDiagnostics` compares a diagnostic stateless +reference-up baseline with the canonical transported frames over the same +distances. The report captures: - per-sample tangent, normal, binormal, frame, roll/twist, and matrix orientation @@ -124,13 +133,15 @@ dependencies, Unity code, or a production visualization contract. ### `TrackEvaluator` scalar behavior -`TrackEvaluator.EvaluateFrameAtDistance` remains deterministic and -context-free. A scalar call still evaluates one station distance independently, -without hidden frame history or cached traversal state. +Historical Milestone 47 state: `TrackEvaluator.EvaluateFrameAtDistance` used +stateless reference-up behavior, and `EvaluateFramesAtDistances` was +scalar-equivalent to that stateless path. -`TrackEvaluator.EvaluateFramesAtDistances` also remains scalar-equivalent. The -current batch parity tests continue to verify that batch outputs match repeated -scalar evaluation for the same distances. +Current runtime state: scalar and batch frame APIs remain deterministic and +station-equivalent, but both sample the same compiled canonical +transported-frame history. Query order and query density do not define runtime +history; `TrackSamplingOptions.TransportSamplesPerSegment` controls compiled +transport node density. ### `TrackFrame` contract @@ -162,9 +173,12 @@ does not change train pose DTOs, validation, or regression snapshots. ### `BankingProfile` runtime behavior -No `BankingProfile` runtime behavior exists yet. The current runtime roll source -is still `TrackSegment.RollRadians`, and that value still represents constant -segment roll in the existing evaluator path. +Historical Milestone 47 state: no `BankingProfile` runtime behavior existed yet. + +Current runtime state: `BankingProfile` is supported through explicit opt-in +sampling paths such as `BankingProfileSampler.SampleFramesAtDistances(...)` and +`TrainCarTransformProvider.EvaluateTrainPose(..., BankingProfile)`. The default +train-pose path still uses segment/evaluator roll behavior. ## Why This Is A Safe Foundation For `BankingProfile` @@ -174,10 +188,10 @@ otherwise blur: 1. Base-frame stability along the centerline. 2. Authored roll angle around the sampled tangent. -The transported sampler proves that Quantum can evaluate a smoother unrolled -base frame over an ordered distance sequence without changing scalar -`TrackEvaluator` behavior. The comparison diagnostics prove that stateless and -transported frames can be measured side by side over the same fixtures. +The transported sampler proved that Quantum could evaluate a smoother unrolled +base frame before it became the canonical runtime path. The comparison +diagnostics still prove that stateless baseline and transported frames can be +measured side by side over the same fixtures. That gives the next milestone a clear place to start: implement `BankingProfile` as a roll-angle scalar sampled by distance, then apply that diff --git a/docs/design/transported-frame-sampler.md b/docs/design/transported-frame-sampler.md index 175c529..1fdab88 100644 --- a/docs/design/transported-frame-sampler.md +++ b/docs/design/transported-frame-sampler.md @@ -10,25 +10,37 @@ extents, and stored in `Quantum.Track.TrackFrame.Distance`. Roll is applied afte base-frame transport. `TransportedTrackFrameSampler` remains only as an obsolete compatibility facade. -## Context +Milestone 151 refresh: `TrackSamplingOptions` is the source of truth for +compiled arc-length and transported-frame sample density. The default +`CompiledTrackRuntime` constructor uses `TrackSamplingOptions.Default`; custom +runtime options may change transported-frame node density while preserving +centerline position, tangent, and station-distance semantics. Query-list density +and ordering do not define frame history for `TrackEvaluator`; the compiled +runtime transport history does. -Quantum's current public coaster-domain sampling lane is: +The rest of this note is retained as historical rationale. Statements below +about "future" transported behavior or "current" stateless evaluator behavior +describe the pre-Milestone 138 state, not the current runtime contract. See +`docs/public-coaster-api-boundary.md` for the current public behavior summary. + +## Historical Context + +Quantum's public coaster-domain sampling lane was, and remains: ```text TrackDocument -> TrackEvaluator -> TrackFrame ``` -`TrackEvaluator.EvaluateFrameAtDistance` and -`TrackEvaluator.EvaluateFramesAtDistances` already return finite, -orthonormalized `TrackFrame` values for station-distance sampling. The current -frame construction is intentionally simple and deterministic: evaluate the -centerline position and tangent, choose a reference-up axis, build normal and -binormal from cross products, then apply the segment roll around the tangent. +Before canonical runtime transport, `TrackEvaluator.EvaluateFrameAtDistance` +and `TrackEvaluator.EvaluateFramesAtDistances` returned finite, +orthonormalized `TrackFrame` values by evaluating the centerline position and +tangent, choosing a reference-up axis, building normal/binormal from cross +products, then applying segment roll around the tangent. -That behavior is good enough for the current prototype, but it is stateless. -Each frame is built independently from the sampled tangent. A future transported -frame sampler should make the unrolled base frame continuous along the -centerline before any banking or roll profile is applied. +That older behavior was deterministic but stateless: each frame was built +independently from the sampled tangent. The implemented runtime now compiles a +transported unrolled base-frame history and samples scalar and batch frames from +that same history before applying segment roll or an explicit `BankingProfile`. ## Problem With Stateless Reference-Up Frames @@ -52,20 +64,25 @@ fallback reference axis and orthonormalizing the result. That protects frame validity, but it does not guarantee visual or diagnostic continuity through vertical tangent regions. -## Proposed Transported Sampling Concept +## Implemented Transported Sampling Shape -A future transported sampler should operate over an ordered station-distance -sequence. The basic shape is: +The implemented canonical sampler operates over a compiled station-distance +node sequence, not over the caller's requested query sequence. The basic shape +is: 1. Validate finite input distances and resolve the document length policy. -2. Evaluate centerline positions and tangents at each requested distance. -3. Seed the first unrolled normal from a deterministic source. -4. Move from sample to sample, carrying the previous unrolled normal forward. -5. Project or minimally rotate that previous normal onto the current tangent's - perpendicular plane. -6. Rebuild `Binormal = Tangent x Normal` and re-orthonormalize. -7. Align signs against the previous frame to avoid accidental 180 degree flips. -8. Apply roll/banking around the current tangent after base-frame transport. +2. Compile transport nodes from the measured segment lengths using + `TrackSamplingOptions.TransportSamplesPerSegment`. +3. Seed the first unrolled normal from authored start-pose data when present, + otherwise from a deterministic fallback. +4. Move across compiled nodes, carrying the previous unrolled normal forward. +5. Transport that previous normal onto the current tangent's perpendicular + plane with the shared rotation-minimizing transport helper. +6. For a requested station distance, use the preceding compiled node and + transport from that node to the exact sampled tangent. +7. Rebuild `Binormal = Tangent x Normal` and re-orthonormalize. +8. Apply segment roll or profile banking around the current tangent after + base-frame transport. 9. Return the existing `TrackFrame` contract. This is parallel-transport-style sampling: the frame changes as little as @@ -82,58 +99,33 @@ or geometry libraries before growing broad custom math code. The first frame needs a deterministic unrolled normal. Candidate policies: -- Use the current stateless `TrackEvaluator` normal at the first distance, then - project it onto the first tangent plane. +- Historical candidate: seed from the then-current stateless evaluator normal + at the first distance, then project it onto the first tangent plane. - Use an explicitly supplied seed normal from a caller or document-level authoring setting. - Use a deterministic fallback axis least aligned with the first tangent. -For early runtime work, the safest default is probably to seed from the current -evaluator so existing unbanked starting orientation remains familiar. The -sampler should still have a fallback for degenerate or non-finite seed data. +Historical candidate policies included seeding from the then-current stateless +evaluator. The implemented runtime seeds from authored start-pose normal data +when present and otherwise uses deterministic fallback-axis projection rules. +The sampler still has fallback behavior for degenerate or non-finite seed data. ## Relationship To `TrackEvaluator.EvaluateFrameAtDistance` -`EvaluateFrameAtDistance(double)` is scalar. It has no previous sample, no next -sample, and no caller-provided traversal direction. That makes true transported -frame behavior ambiguous. - -Recommended policy: - -- Keep scalar evaluation unchanged until a deliberate behavior-changing - milestone. -- Treat scalar evaluation as the stable compatibility path for consumers that - need one independent frame. -- If a transported result is needed for a single visual marker, sample it - through an explicit transported sampler using a deterministic sequence and - then select the desired frame. -- Do not make scalar evaluation secretly depend on hidden cached history, - because that would make results order-dependent and difficult to test. - -Longer term, `TrackEvaluator` may expose an explicit transported-frame entry -point, but the scalar method should remain deterministic and context-free unless -its contract is intentionally revised. +Superseded guidance: the original note recommended keeping scalar evaluation +stateless until a deliberate migration. That migration has happened. Scalar +evaluation is still deterministic and context-free from the caller's point of +view, but it samples the compiled canonical transported-frame history rather +than rebuilding an independent reference-up frame. ## Relationship To `TrackEvaluator.EvaluateFramesAtDistances` -Batch frame sampling is the natural home for transported behavior because the -ordered distance list provides traversal context. However, the current batch API -is tested as scalar-equivalent: each returned frame should match evaluating that -same distance independently. - -Recommended migration path: - -- Add a future explicit API or helper for transported batch sampling rather than - changing `EvaluateFramesAtDistances` silently. -- Require distances to be non-decreasing, or define an internal sort/restore - policy, before transport is enabled. -- Preserve current `EvaluateFramesAtDistances` semantics until tests and - downstream consumers are intentionally migrated. -- If transported behavior eventually becomes the default batch policy, update - scalar parity tests and document the ordering requirement clearly. - -An explicit name such as `EvaluateTransportedFramesAtDistances` or a -`TransportedTrackFrameSampler` would make the behavior visible and reviewable. +Superseded guidance: the original note described batch sampling as a possible +future home for transported behavior. Current batch evaluation samples the same +compiled transported history as scalar evaluation, preserves caller order, +allows duplicate and unordered finite distances, and remains scalar-equivalent +at each station. `TransportedTrackFrameSampler` now forwards to canonical +evaluation and remains only for obsolete compatibility coverage. ## Relationship To Frame Diagnostics @@ -210,15 +202,17 @@ wrap boundary. ## Deterministic Tests Needed Later -When runtime behavior is added, tests should cover: +The runtime behavior now exists. Tests should continue to cover: -- stateless scalar evaluation remains deterministic and context-free -- transported batch sampling rejects or clearly handles unsorted distances +- scalar and batch evaluation remain deterministic and station-equivalent +- batch sampling clearly handles unsorted and duplicate distances - all transported frames are finite and orthonormal - tangent output matches the underlying centerline tangent samples - straight horizontal track preserves the expected starting frame - vertical or near-vertical tangent sequences avoid reference-axis flip spikes - repeated sampling of the same document and distances returns identical frames +- custom transport sample densities preserve centerline samples and converge + orientation toward denser transported-frame histories - open-track endpoint behavior is stable - closed-loop residual twist policy is deterministic once loop semantics exist - zero banking leaves transported base frames unrolled