diff --git a/Quantum.Tests/Track/SupportAnchorSpacingGeneratorTests.cs b/Quantum.Tests/Track/SupportAnchorSpacingGeneratorTests.cs new file mode 100644 index 0000000..3f1ed41 --- /dev/null +++ b/Quantum.Tests/Track/SupportAnchorSpacingGeneratorTests.cs @@ -0,0 +1,214 @@ +using Quantum.Track; + +namespace Quantum.Tests; + +public sealed class SupportAnchorSpacingGeneratorTests +{ + private const double Tolerance = 1e-9; + + [Fact] + public void Generate_WithExactSpacing_ReturnsEvenCandidatesAndIntervals() + { + SupportAnchorSpacingResult result = SupportAnchorSpacingGenerator.Generate( + startDistance: 0.0, + endDistance: 20.0, + targetSpacing: 5.0); + + AssertDistances(result, 0.0, 5.0, 10.0, 15.0, 20.0); + AssertIntervals(result, 5.0, 5.0, 5.0, 5.0); + AssertDoubleNear(0.0, result.Remainder.StartGap); + AssertDoubleNear(0.0, result.Remainder.EndGap); + AssertDoubleNear(0.0, result.Remainder.EndRemainder); + Assert.False(result.HasWarnings); + } + + [Fact] + public void Generate_WithStartOffset_ShiftsFirstCandidateAndPreservesTargetIntervals() + { + SupportAnchorSpacingResult result = SupportAnchorSpacingGenerator.Generate( + startDistance: 10.0, + endDistance: 24.0, + targetSpacing: 4.0, + startOffset: 2.0); + + AssertDistances(result, 12.0, 16.0, 20.0, 24.0); + AssertIntervals(result, 4.0, 4.0, 4.0); + AssertDoubleNear(2.0, result.Remainder.StartGap); + AssertDoubleNear(0.0, result.Remainder.EndGap); + Assert.False(result.HasWarnings); + } + + [Fact] + public void Generate_WithEndRemainder_ReportsEndGapAndWarning() + { + SupportAnchorSpacingResult result = SupportAnchorSpacingGenerator.Generate( + startDistance: 0.0, + endDistance: 10.0, + targetSpacing: 3.0); + + AssertDistances(result, 0.0, 3.0, 6.0, 9.0); + AssertIntervals(result, 3.0, 3.0, 3.0); + AssertDoubleNear(1.0, result.Remainder.EndGap); + Assert.Contains( + result.Warnings, + warning => warning.Code == SupportAnchorSpacingWarningCode.UnevenEndRemainder && + warning.StartDistance == 9.0 && + warning.EndDistance == 10.0); + } + + [Fact] + public void Generate_WithExcludedZone_RemovesCandidatesAndReportsActualGap() + { + var exclusions = new[] + { + new SupportAnchorExcludedRange(8.0, 12.0) + }; + + SupportAnchorSpacingResult result = SupportAnchorSpacingGenerator.Generate( + startDistance: 0.0, + endDistance: 20.0, + targetSpacing: 5.0, + excludedRanges: exclusions); + + AssertDistances(result, 0.0, 5.0, 15.0, 20.0); + AssertIntervals(result, 5.0, 10.0, 5.0); + Assert.True(result.Intervals[1].CrossesExcludedRange); + Assert.Contains( + result.Warnings, + warning => warning.Code == SupportAnchorSpacingWarningCode.ExcludedAnchorCandidate && + warning.Distance == 10.0 && + warning.ExcludedRangeIndex == 0); + Assert.Contains( + result.Warnings, + warning => warning.Code == SupportAnchorSpacingWarningCode.ExcludedGap && + warning.StartDistance == 5.0 && + warning.EndDistance == 15.0 && + warning.ExcludedRangeIndex == 0); + } + + [Fact] + public void Generate_WithInvalidTargetSpacing_ReturnsWarningWithoutCandidates() + { + SupportAnchorSpacingResult result = SupportAnchorSpacingGenerator.Generate( + startDistance: 0.0, + endDistance: 10.0, + targetSpacing: 0.0); + + Assert.Empty(result.Candidates); + Assert.Empty(result.CandidateDistances); + Assert.Empty(result.Intervals); + AssertDoubleNear(10.0, result.Remainder.TrackSpan); + Assert.Contains( + result.Warnings, + warning => warning.Code == SupportAnchorSpacingWarningCode.InvalidTargetSpacing); + } + + [Fact] + public void Generate_WithInvalidSpacingRange_ReturnsWarningWithoutCandidates() + { + SupportAnchorSpacingResult result = SupportAnchorSpacingGenerator.Generate( + startDistance: 5.0, + endDistance: 5.0, + targetSpacing: 2.0); + + Assert.Empty(result.Candidates); + Assert.Contains( + result.Warnings, + warning => warning.Code == SupportAnchorSpacingWarningCode.InvalidSpacingRange && + warning.StartDistance == 5.0 && + warning.EndDistance == 5.0); + } + + [Fact] + public void Generate_WithInvalidExcludedRanges_WarnsAndIgnoresThoseRanges() + { + var exclusions = new[] + { + new SupportAnchorExcludedRange(8.0, 8.0), + new SupportAnchorExcludedRange(double.NaN, 12.0), + new SupportAnchorExcludedRange(20.0, 25.0) + }; + + SupportAnchorSpacingResult result = SupportAnchorSpacingGenerator.Generate( + startDistance: 0.0, + endDistance: 10.0, + targetSpacing: 5.0, + excludedRanges: exclusions); + + AssertDistances(result, 0.0, 5.0, 10.0); + AssertIntervals(result, 5.0, 5.0); + Assert.Contains( + result.Warnings, + warning => warning.Code == SupportAnchorSpacingWarningCode.InvalidExcludedRange && + warning.ExcludedRangeIndex == 0); + Assert.Contains( + result.Warnings, + warning => warning.Code == SupportAnchorSpacingWarningCode.InvalidExcludedRange && + warning.ExcludedRangeIndex == 1); + Assert.DoesNotContain( + result.Warnings, + warning => warning.Code == SupportAnchorSpacingWarningCode.ExcludedAnchorCandidate); + } + + [Fact] + public void Generate_OutputIsDeterministicForRepeatedRuns() + { + var request = new SupportAnchorSpacingRequest( + startDistance: 0.0, + endDistance: 30.0, + targetSpacing: 4.0, + startOffset: 1.0, + excludedRanges: new[] + { + new SupportAnchorExcludedRange(16.0, 20.0), + new SupportAnchorExcludedRange(6.0, 13.0) + }); + + SupportAnchorSpacingResult first = SupportAnchorSpacingGenerator.Generate(request); + SupportAnchorSpacingResult second = SupportAnchorSpacingGenerator.Generate(request); + + Assert.Equal(first.CandidateDistances, second.CandidateDistances); + Assert.Equal( + first.Intervals.Select(interval => interval.Length), + second.Intervals.Select(interval => interval.Length)); + Assert.Equal( + first.Warnings.Select(warning => warning.Code), + second.Warnings.Select(warning => warning.Code)); + Assert.Equal( + first.Warnings.Select(warning => warning.Distance), + second.Warnings.Select(warning => warning.Distance)); + Assert.Equal( + first.Warnings.Select(warning => warning.ExcludedRangeIndex), + second.Warnings.Select(warning => warning.ExcludedRangeIndex)); + } + + private static void AssertDistances(SupportAnchorSpacingResult result, params double[] expectedDistances) + { + Assert.Equal(expectedDistances.Length, result.Candidates.Count); + Assert.Equal(expectedDistances.Length, result.CandidateDistances.Count); + + for (int i = 0; i < expectedDistances.Length; i++) + { + Assert.Equal(i, result.Candidates[i].Index); + AssertDoubleNear(expectedDistances[i], result.Candidates[i].Distance); + AssertDoubleNear(expectedDistances[i], result.CandidateDistances[i]); + } + } + + private static void AssertIntervals(SupportAnchorSpacingResult result, params double[] expectedLengths) + { + Assert.Equal(expectedLengths.Length, result.Intervals.Count); + + for (int i = 0; i < expectedLengths.Length; i++) + { + Assert.Equal(i, result.Intervals[i].StartCandidateIndex); + Assert.Equal(i + 1, result.Intervals[i].EndCandidateIndex); + AssertDoubleNear(expectedLengths[i], result.Intervals[i].Length); + } + } + + private static void AssertDoubleNear(double expected, double actual) + { + Assert.InRange(System.Math.Abs(expected - actual), 0.0, Tolerance); + } +} diff --git a/Quantum.Track/SupportAnchorCandidate.cs b/Quantum.Track/SupportAnchorCandidate.cs new file mode 100644 index 0000000..07e40d4 --- /dev/null +++ b/Quantum.Track/SupportAnchorCandidate.cs @@ -0,0 +1,18 @@ +namespace Quantum.Track +{ + /// + /// One generated support anchor candidate at a canonical station distance. + /// + public sealed class SupportAnchorCandidate + { + internal SupportAnchorCandidate(int index, double distance) + { + Index = index; + Distance = distance; + } + + public int Index { get; } + + public double Distance { get; } + } +} diff --git a/Quantum.Track/SupportAnchorExcludedRange.cs b/Quantum.Track/SupportAnchorExcludedRange.cs new file mode 100644 index 0000000..aeee849 --- /dev/null +++ b/Quantum.Track/SupportAnchorExcludedRange.cs @@ -0,0 +1,18 @@ +namespace Quantum.Track +{ + /// + /// Canonical station-distance range where support anchor candidates should not be placed. + /// + public sealed class SupportAnchorExcludedRange + { + public SupportAnchorExcludedRange(double startDistance, double endDistance) + { + StartDistance = startDistance; + EndDistance = endDistance; + } + + public double StartDistance { get; } + + public double EndDistance { get; } + } +} diff --git a/Quantum.Track/SupportAnchorSpacingGenerator.cs b/Quantum.Track/SupportAnchorSpacingGenerator.cs new file mode 100644 index 0000000..3bed5fb --- /dev/null +++ b/Quantum.Track/SupportAnchorSpacingGenerator.cs @@ -0,0 +1,353 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Quantum.Track +{ + /// + /// Generates support anchor candidate distances from canonical station distance only. + /// + public static class SupportAnchorSpacingGenerator + { + private const double Tolerance = 1e-9; + + public static SupportAnchorSpacingResult Generate( + double startDistance, + double endDistance, + double targetSpacing, + double startOffset = 0.0, + IReadOnlyList? excludedRanges = null) + { + return Generate(new SupportAnchorSpacingRequest( + startDistance, + endDistance, + targetSpacing, + startOffset, + excludedRanges)); + } + + public static SupportAnchorSpacingResult Generate(SupportAnchorSpacingRequest request) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + var warnings = new List(); + ValidateGlobalInputs(request, warnings); + + if (warnings.Any(IsBlockingWarning)) + { + return CreateEmptyResult(request, warnings); + } + + List validExcludedRanges = GetValidExcludedRanges(request, warnings); + var candidates = new List(); + double firstRawDistance = request.StartDistance + request.StartOffset; + int generatedIndex = 0; + + while (true) + { + double distance = firstRawDistance + (generatedIndex * request.TargetSpacing); + if (distance > request.EndDistance + Tolerance) + { + break; + } + + if (IsNear(distance, request.EndDistance)) + { + distance = request.EndDistance; + } + + IndexedExcludedRange? excludedRange = FindContainingRange(validExcludedRanges, distance); + if (excludedRange is null) + { + candidates.Add(new SupportAnchorCandidate(candidates.Count, distance)); + } + else + { + warnings.Add(new SupportAnchorSpacingWarning( + SupportAnchorSpacingWarningCode.ExcludedAnchorCandidate, + $"Anchor candidate at distance {distance} was skipped because it is inside excluded range {excludedRange.Value.OriginalIndex}.", + distance: distance, + startDistance: excludedRange.Value.StartDistance, + endDistance: excludedRange.Value.EndDistance, + excludedRangeIndex: excludedRange.Value.OriginalIndex)); + } + + generatedIndex++; + } + + if (candidates.Count == 0) + { + warnings.Add(new SupportAnchorSpacingWarning( + SupportAnchorSpacingWarningCode.NoAnchorCandidates, + "No support anchor candidates were generated for the supplied spacing inputs.", + startDistance: request.StartDistance, + endDistance: request.EndDistance)); + + return CreateEmptyResult(request, warnings); + } + + List intervals = BuildIntervals(candidates, validExcludedRanges, warnings); + SupportAnchorSpacingRemainder remainder = BuildRemainder(request, candidates); + + if (remainder.EndRemainder > Tolerance) + { + warnings.Add(new SupportAnchorSpacingWarning( + SupportAnchorSpacingWarningCode.UnevenEndRemainder, + $"End gap is {remainder.EndRemainder}, so the spacing range does not end on an anchor candidate.", + startDistance: candidates[candidates.Count - 1].Distance, + endDistance: request.EndDistance)); + } + + return new SupportAnchorSpacingResult(candidates, intervals, remainder, warnings); + } + + private static void ValidateGlobalInputs( + SupportAnchorSpacingRequest request, + List warnings) + { + if (!IsFinite(request.StartDistance)) + { + warnings.Add(new SupportAnchorSpacingWarning( + SupportAnchorSpacingWarningCode.InvalidStartDistance, + "Start distance must be finite.", + distance: request.StartDistance)); + } + + if (!IsFinite(request.EndDistance)) + { + warnings.Add(new SupportAnchorSpacingWarning( + SupportAnchorSpacingWarningCode.InvalidEndDistance, + "End distance must be finite.", + distance: request.EndDistance)); + } + + if (!IsFinite(request.TargetSpacing) || request.TargetSpacing <= 0.0) + { + warnings.Add(new SupportAnchorSpacingWarning( + SupportAnchorSpacingWarningCode.InvalidTargetSpacing, + "Target spacing must be finite and greater than zero.", + distance: request.TargetSpacing)); + } + + if (!IsFinite(request.StartOffset) || request.StartOffset < 0.0) + { + warnings.Add(new SupportAnchorSpacingWarning( + SupportAnchorSpacingWarningCode.InvalidStartOffset, + "Start offset must be finite and greater than or equal to zero.", + distance: request.StartOffset)); + } + + if (IsFinite(request.StartDistance) && + IsFinite(request.EndDistance) && + request.StartDistance >= request.EndDistance) + { + warnings.Add(new SupportAnchorSpacingWarning( + SupportAnchorSpacingWarningCode.InvalidSpacingRange, + "Start distance must be less than end distance.", + startDistance: request.StartDistance, + endDistance: request.EndDistance)); + } + } + + private static bool IsBlockingWarning(SupportAnchorSpacingWarning warning) + { + return warning.Code == SupportAnchorSpacingWarningCode.InvalidStartDistance || + warning.Code == SupportAnchorSpacingWarningCode.InvalidEndDistance || + warning.Code == SupportAnchorSpacingWarningCode.InvalidTargetSpacing || + warning.Code == SupportAnchorSpacingWarningCode.InvalidStartOffset || + warning.Code == SupportAnchorSpacingWarningCode.InvalidSpacingRange; + } + + private static List GetValidExcludedRanges( + SupportAnchorSpacingRequest request, + List warnings) + { + var ranges = new List(); + + for (int i = 0; i < request.ExcludedRanges.Count; i++) + { + SupportAnchorExcludedRange range = request.ExcludedRanges[i]; + if (!IsFinite(range.StartDistance) || + !IsFinite(range.EndDistance) || + range.StartDistance >= range.EndDistance) + { + warnings.Add(new SupportAnchorSpacingWarning( + SupportAnchorSpacingWarningCode.InvalidExcludedRange, + $"Excluded range {i} must use finite distances with start less than end.", + startDistance: range.StartDistance, + endDistance: range.EndDistance, + excludedRangeIndex: i)); + continue; + } + + if (range.EndDistance < request.StartDistance || range.StartDistance > request.EndDistance) + { + continue; + } + + ranges.Add(new IndexedExcludedRange( + originalIndex: i, + startDistance: System.Math.Max(request.StartDistance, range.StartDistance), + endDistance: System.Math.Min(request.EndDistance, range.EndDistance))); + } + + ranges.Sort((left, right) => + { + int startComparison = left.StartDistance.CompareTo(right.StartDistance); + if (startComparison != 0) + { + return startComparison; + } + + int endComparison = left.EndDistance.CompareTo(right.EndDistance); + if (endComparison != 0) + { + return endComparison; + } + + return left.OriginalIndex.CompareTo(right.OriginalIndex); + }); + + return ranges; + } + + private static List BuildIntervals( + IReadOnlyList candidates, + IReadOnlyList excludedRanges, + List warnings) + { + var intervals = new List(); + + for (int i = 1; i < candidates.Count; i++) + { + SupportAnchorCandidate start = candidates[i - 1]; + SupportAnchorCandidate end = candidates[i]; + IndexedExcludedRange? crossedRange = FindOverlappingRange( + excludedRanges, + start.Distance, + end.Distance); + bool crossesExcludedRange = crossedRange != null; + + intervals.Add(new SupportAnchorSpacingInterval( + start.Index, + end.Index, + start.Distance, + end.Distance, + crossesExcludedRange)); + + if (crossedRange != null) + { + warnings.Add(new SupportAnchorSpacingWarning( + SupportAnchorSpacingWarningCode.ExcludedGap, + $"Spacing interval from {start.Distance} to {end.Distance} crosses excluded range {crossedRange.Value.OriginalIndex}.", + startDistance: start.Distance, + endDistance: end.Distance, + excludedRangeIndex: crossedRange.Value.OriginalIndex)); + } + } + + return intervals; + } + + private static SupportAnchorSpacingRemainder BuildRemainder( + SupportAnchorSpacingRequest request, + IReadOnlyList candidates) + { + SupportAnchorCandidate first = candidates[0]; + SupportAnchorCandidate last = candidates[candidates.Count - 1]; + return new SupportAnchorSpacingRemainder( + trackSpan: request.EndDistance - request.StartDistance, + firstCandidateDistance: first.Distance, + lastCandidateDistance: last.Distance, + startGap: first.Distance - request.StartDistance, + endGap: request.EndDistance - last.Distance); + } + + private static SupportAnchorSpacingResult CreateEmptyResult( + SupportAnchorSpacingRequest request, + List warnings) + { + double trackSpan = IsFinite(request.StartDistance) && + IsFinite(request.EndDistance) && + request.EndDistance > request.StartDistance + ? request.EndDistance - request.StartDistance + : 0.0; + var remainder = new SupportAnchorSpacingRemainder( + trackSpan, + firstCandidateDistance: null, + lastCandidateDistance: null, + startGap: trackSpan, + endGap: trackSpan); + + return new SupportAnchorSpacingResult( + Array.Empty(), + Array.Empty(), + remainder, + warnings); + } + + private static IndexedExcludedRange? FindContainingRange( + IReadOnlyList ranges, + double distance) + { + for (int i = 0; i < ranges.Count; i++) + { + IndexedExcludedRange range = ranges[i]; + if (distance >= range.StartDistance - Tolerance && + distance <= range.EndDistance + Tolerance) + { + return range; + } + } + + return null; + } + + private static IndexedExcludedRange? FindOverlappingRange( + IReadOnlyList ranges, + double startDistance, + double endDistance) + { + for (int i = 0; i < ranges.Count; i++) + { + IndexedExcludedRange range = ranges[i]; + if (range.StartDistance < endDistance - Tolerance && + range.EndDistance > startDistance + Tolerance) + { + return range; + } + } + + return null; + } + + private static bool IsFinite(double value) + { + return !(double.IsNaN(value) || double.IsInfinity(value)); + } + + private static bool IsNear(double left, double right) + { + return System.Math.Abs(left - right) <= Tolerance; + } + + private struct IndexedExcludedRange + { + public IndexedExcludedRange(int originalIndex, double startDistance, double endDistance) + { + OriginalIndex = originalIndex; + StartDistance = startDistance; + EndDistance = endDistance; + } + + public int OriginalIndex { get; } + + public double StartDistance { get; } + + public double EndDistance { get; } + } + } +} diff --git a/Quantum.Track/SupportAnchorSpacingInterval.cs b/Quantum.Track/SupportAnchorSpacingInterval.cs new file mode 100644 index 0000000..6c16703 --- /dev/null +++ b/Quantum.Track/SupportAnchorSpacingInterval.cs @@ -0,0 +1,35 @@ +namespace Quantum.Track +{ + /// + /// Actual spacing between two generated support anchor candidates. + /// + public sealed class SupportAnchorSpacingInterval + { + internal SupportAnchorSpacingInterval( + int startCandidateIndex, + int endCandidateIndex, + double startDistance, + double endDistance, + bool crossesExcludedRange) + { + StartCandidateIndex = startCandidateIndex; + EndCandidateIndex = endCandidateIndex; + StartDistance = startDistance; + EndDistance = endDistance; + Length = endDistance - startDistance; + CrossesExcludedRange = crossesExcludedRange; + } + + public int StartCandidateIndex { get; } + + public int EndCandidateIndex { get; } + + public double StartDistance { get; } + + public double EndDistance { get; } + + public double Length { get; } + + public bool CrossesExcludedRange { get; } + } +} diff --git a/Quantum.Track/SupportAnchorSpacingRemainder.cs b/Quantum.Track/SupportAnchorSpacingRemainder.cs new file mode 100644 index 0000000..289127e --- /dev/null +++ b/Quantum.Track/SupportAnchorSpacingRemainder.cs @@ -0,0 +1,34 @@ +namespace Quantum.Track +{ + /// + /// Start and end gaps left by support anchor spacing. + /// + public sealed class SupportAnchorSpacingRemainder + { + internal SupportAnchorSpacingRemainder( + double trackSpan, + double? firstCandidateDistance, + double? lastCandidateDistance, + double startGap, + double endGap) + { + TrackSpan = trackSpan; + FirstCandidateDistance = firstCandidateDistance; + LastCandidateDistance = lastCandidateDistance; + StartGap = startGap; + EndGap = endGap; + } + + public double TrackSpan { get; } + + public double? FirstCandidateDistance { get; } + + public double? LastCandidateDistance { get; } + + public double StartGap { get; } + + public double EndGap { get; } + + public double EndRemainder => EndGap; + } +} diff --git a/Quantum.Track/SupportAnchorSpacingRequest.cs b/Quantum.Track/SupportAnchorSpacingRequest.cs new file mode 100644 index 0000000..d3b68d2 --- /dev/null +++ b/Quantum.Track/SupportAnchorSpacingRequest.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; + +namespace Quantum.Track +{ + /// + /// Input settings for Support Anchor Spacing V1. + /// + public sealed class SupportAnchorSpacingRequest + { + private readonly IReadOnlyList _excludedRanges; + + public SupportAnchorSpacingRequest( + double startDistance, + double endDistance, + double targetSpacing, + double startOffset = 0.0, + IReadOnlyList? excludedRanges = null) + { + StartDistance = startDistance; + EndDistance = endDistance; + TargetSpacing = targetSpacing; + StartOffset = startOffset; + + if (excludedRanges is null) + { + _excludedRanges = Array.AsReadOnly(Array.Empty()); + return; + } + + var ranges = new SupportAnchorExcludedRange[excludedRanges.Count]; + for (int i = 0; i < excludedRanges.Count; i++) + { + ranges[i] = excludedRanges[i] ?? throw new ArgumentException( + $"Excluded range at index {i} cannot be null.", + nameof(excludedRanges)); + } + + _excludedRanges = Array.AsReadOnly(ranges); + } + + public double StartDistance { get; } + + public double EndDistance { get; } + + public double TargetSpacing { get; } + + public double StartOffset { get; } + + public IReadOnlyList ExcludedRanges => _excludedRanges; + } +} diff --git a/Quantum.Track/SupportAnchorSpacingResult.cs b/Quantum.Track/SupportAnchorSpacingResult.cs new file mode 100644 index 0000000..c929f73 --- /dev/null +++ b/Quantum.Track/SupportAnchorSpacingResult.cs @@ -0,0 +1,96 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Quantum.Track +{ + /// + /// Immutable result from Support Anchor Spacing V1. + /// + public sealed class SupportAnchorSpacingResult + { + private readonly IReadOnlyList _candidates; + private readonly IReadOnlyList _candidateDistances; + private readonly IReadOnlyList _intervals; + private readonly IReadOnlyList _warnings; + + internal SupportAnchorSpacingResult( + IEnumerable candidates, + IEnumerable intervals, + SupportAnchorSpacingRemainder remainder, + IEnumerable warnings) + { + if (candidates is null) + { + throw new ArgumentNullException(nameof(candidates)); + } + + if (intervals is null) + { + throw new ArgumentNullException(nameof(intervals)); + } + + if (remainder is null) + { + throw new ArgumentNullException(nameof(remainder)); + } + + if (warnings is null) + { + throw new ArgumentNullException(nameof(warnings)); + } + + SupportAnchorCandidate[] candidateArray = candidates.ToArray(); + SupportAnchorSpacingInterval[] intervalArray = intervals.ToArray(); + SupportAnchorSpacingWarning[] warningArray = warnings.ToArray(); + + for (int i = 0; i < candidateArray.Length; i++) + { + if (candidateArray[i] is null) + { + throw new ArgumentException( + "Candidate collection cannot contain null entries.", + nameof(candidates)); + } + } + + for (int i = 0; i < intervalArray.Length; i++) + { + if (intervalArray[i] is null) + { + throw new ArgumentException( + "Interval collection cannot contain null entries.", + nameof(intervals)); + } + } + + for (int i = 0; i < warningArray.Length; i++) + { + if (warningArray[i] is null) + { + throw new ArgumentException( + "Warning collection cannot contain null entries.", + nameof(warnings)); + } + } + + _candidates = Array.AsReadOnly(candidateArray); + _candidateDistances = Array.AsReadOnly(candidateArray.Select(candidate => candidate.Distance).ToArray()); + _intervals = Array.AsReadOnly(intervalArray); + Remainder = remainder; + _warnings = Array.AsReadOnly(warningArray); + } + + public IReadOnlyList Candidates => _candidates; + + public IReadOnlyList CandidateDistances => _candidateDistances; + + public IReadOnlyList Intervals => _intervals; + + public SupportAnchorSpacingRemainder Remainder { get; } + + public IReadOnlyList Warnings => _warnings; + + public bool HasWarnings => _warnings.Count > 0; + } +} diff --git a/Quantum.Track/SupportAnchorSpacingWarning.cs b/Quantum.Track/SupportAnchorSpacingWarning.cs new file mode 100644 index 0000000..7bb82f4 --- /dev/null +++ b/Quantum.Track/SupportAnchorSpacingWarning.cs @@ -0,0 +1,50 @@ +namespace Quantum.Track +{ + public enum SupportAnchorSpacingWarningCode + { + InvalidStartDistance = 0, + InvalidEndDistance = 1, + InvalidTargetSpacing = 2, + InvalidStartOffset = 3, + InvalidSpacingRange = 4, + InvalidExcludedRange = 5, + ExcludedAnchorCandidate = 6, + ExcludedGap = 7, + UnevenEndRemainder = 8, + NoAnchorCandidates = 9 + } + + /// + /// Non-fatal support anchor spacing diagnostic. + /// + public sealed class SupportAnchorSpacingWarning + { + internal SupportAnchorSpacingWarning( + SupportAnchorSpacingWarningCode code, + string message, + double? distance = null, + double? startDistance = null, + double? endDistance = null, + int? excludedRangeIndex = null) + { + Code = code; + Message = message; + Distance = distance; + StartDistance = startDistance; + EndDistance = endDistance; + ExcludedRangeIndex = excludedRangeIndex; + } + + public SupportAnchorSpacingWarningCode Code { get; } + + public string Message { get; } + + public double? Distance { get; } + + public double? StartDistance { get; } + + public double? EndDistance { get; } + + public int? ExcludedRangeIndex { get; } + } +} diff --git a/docs/track/support-anchor-spacing-v1.md b/docs/track/support-anchor-spacing-v1.md new file mode 100644 index 0000000..3654186 --- /dev/null +++ b/docs/track/support-anchor-spacing-v1.md @@ -0,0 +1,38 @@ +# Support Anchor Spacing V1 + +Support Anchor Spacing V1 is a small backend-only Quantum.Track domain utility for +placing support anchor candidates along canonical station distance. + +It is not full support generation. + +## Scope + +The V1 API accepts: + +- Start distance. +- End distance. +- Target spacing. +- Optional start offset. +- Excluded distance ranges. + +It returns: + +- Generated anchor candidate distances. +- Actual intervals between generated candidates. +- Start gap and end remainder information. +- Warnings for invalid inputs, excluded candidates or gaps, and uneven end + remainders. + +## Non-Goals + +Support Anchor Spacing V1 does not: + +- Snap anchors to terrain. +- Evaluate support geometry. +- Choose support prefabs. +- Create meshes. +- Depend on Unity, Unreal, or renderer APIs. +- Change `TrackEvaluator` behavior. + +The output is intentionally just canonical station-distance data so later support +systems can consume it without coupling the Quantum backend to a visualization target.