diff --git a/Quantum.Editor.Avalonia/Controls/AuthoringNumericControls.cs b/Quantum.Editor.Avalonia/Controls/AuthoringNumericControls.cs
new file mode 100644
index 0000000..9e0e5d6
--- /dev/null
+++ b/Quantum.Editor.Avalonia/Controls/AuthoringNumericControls.cs
@@ -0,0 +1,204 @@
+using System.Globalization;
+using System.Runtime.CompilerServices;
+using Avalonia.Controls;
+
+namespace Quantum.Editor.Avalonia.Controls;
+
+internal enum AuthoringNumericParameterKind
+{
+ LengthMeters = 0,
+ RollDegrees = 1,
+ SignedRadiusMeters = 2,
+ CurvaturePerMeter = 3
+}
+
+///
+/// Shared Avalonia presentation settings for transitional section-authoring controls.
+/// Production section constructors remain authoritative for domain validation.
+///
+internal static class AuthoringNumericControls
+{
+ private static readonly ConditionalWeakTable InitialValues = new();
+
+ internal static NumericUpDown Create(
+ string key,
+ AuthoringNumericParameterKind kind,
+ double value)
+ {
+ Settings settings = GetSettings(kind);
+ decimal decimalValue = ToDecimal(value, key);
+ decimal minimum = System.Math.Min(settings.Minimum, decimalValue);
+ decimal maximum = System.Math.Max(settings.Maximum, decimalValue);
+
+ var field = new NumericUpDown
+ {
+ Tag = key,
+ Minimum = minimum,
+ Maximum = maximum,
+ Increment = settings.Increment,
+ FormatString = settings.FormatString,
+ NumberFormat = NumberFormatInfo.InvariantInfo,
+ ParsingNumberStyle = NumberStyles.Float,
+ AllowSpin = true,
+ ShowButtonSpinner = true,
+ ClipValueToMinMax = false,
+ MinHeight = 30
+ };
+ field.Value = decimalValue;
+ field.Text = decimalValue.ToString(field.FormatString, field.NumberFormat);
+ InitialValues.Add(field, new InitialValue(value, decimalValue));
+ return field;
+ }
+
+ internal static double ReadFiniteDouble(NumericUpDown field, string label)
+ {
+ if (field is null)
+ {
+ throw new ArgumentNullException(nameof(field));
+ }
+
+ string text = field.Text?.Trim() ?? string.Empty;
+ if (field.Value.HasValue)
+ {
+ decimal currentValue = field.Value.Value;
+ if (InitialValues.TryGetValue(field, out InitialValue? initialValue) &&
+ string.Equals(
+ text,
+ initialValue.DecimalValue.ToString(
+ field.FormatString,
+ field.NumberFormat),
+ StringComparison.Ordinal))
+ {
+ return initialValue.DoubleValue;
+ }
+
+ string formattedValue = currentValue.ToString(
+ field.FormatString,
+ field.NumberFormat);
+ if (string.Equals(text, formattedValue, StringComparison.Ordinal))
+ {
+ return ToFiniteDouble(currentValue, label);
+ }
+ }
+
+ if (!decimal.TryParse(
+ text,
+ field.ParsingNumberStyle,
+ field.NumberFormat,
+ out decimal parsedValue))
+ {
+ throw new FormatException(
+ $"'{text}' is not a finite invariant-culture number for {label}.");
+ }
+
+ if (parsedValue < field.Minimum || parsedValue > field.Maximum)
+ {
+ throw new FormatException(
+ $"'{text}' is outside the supported range " +
+ $"[{field.Minimum}, {field.Maximum}] for {label}.");
+ }
+
+ return ToFiniteDouble(parsedValue, label);
+ }
+
+ private static Settings GetSettings(AuthoringNumericParameterKind kind)
+ {
+ return kind switch
+ {
+ AuthoringNumericParameterKind.LengthMeters => new Settings(
+ minimum: 0.0m,
+ maximum: 1_000_000.0m,
+ increment: 1.0m,
+ formatString: "0.######"),
+ AuthoringNumericParameterKind.RollDegrees => new Settings(
+ minimum: -36_000.0m,
+ maximum: 36_000.0m,
+ increment: 1.0m,
+ formatString: "0.###"),
+ AuthoringNumericParameterKind.SignedRadiusMeters => new Settings(
+ minimum: -1_000_000.0m,
+ maximum: 1_000_000.0m,
+ increment: 1.0m,
+ formatString: "0.######"),
+ AuthoringNumericParameterKind.CurvaturePerMeter => new Settings(
+ minimum: -100.0m,
+ maximum: 100.0m,
+ increment: 0.001m,
+ formatString: "0.########"),
+ _ => throw new ArgumentOutOfRangeException(
+ nameof(kind),
+ kind,
+ "Unsupported authoring numeric parameter kind.")
+ };
+ }
+
+ private static decimal ToDecimal(double value, string label)
+ {
+ if (!double.IsFinite(value))
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(value),
+ value,
+ $"The initial value for {label} must be finite.");
+ }
+
+ try
+ {
+ return (decimal)value;
+ }
+ catch (OverflowException)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(value),
+ value,
+ $"The initial value for {label} is outside the numeric authoring range.");
+ }
+ }
+
+ private static double ToFiniteDouble(decimal value, string label)
+ {
+ double result = decimal.ToDouble(value);
+ if (!double.IsFinite(result))
+ {
+ throw new FormatException($"The numeric value for {label} must be finite.");
+ }
+
+ return result;
+ }
+
+ private readonly struct Settings
+ {
+ public Settings(
+ decimal minimum,
+ decimal maximum,
+ decimal increment,
+ string formatString)
+ {
+ Minimum = minimum;
+ Maximum = maximum;
+ Increment = increment;
+ FormatString = formatString;
+ }
+
+ public decimal Minimum { get; }
+
+ public decimal Maximum { get; }
+
+ public decimal Increment { get; }
+
+ public string FormatString { get; }
+ }
+
+ private sealed class InitialValue
+ {
+ public InitialValue(double doubleValue, decimal decimalValue)
+ {
+ DoubleValue = doubleValue;
+ DecimalValue = decimalValue;
+ }
+
+ public double DoubleValue { get; }
+
+ public decimal DecimalValue { get; }
+ }
+}
diff --git a/Quantum.Editor.Avalonia/Controls/InspectorPaneControl.axaml b/Quantum.Editor.Avalonia/Controls/InspectorPaneControl.axaml
index 08f3709..307e5a8 100644
--- a/Quantum.Editor.Avalonia/Controls/InspectorPaneControl.axaml
+++ b/Quantum.Editor.Avalonia/Controls/InspectorPaneControl.axaml
@@ -12,6 +12,9 @@
+
inspectorFields = new(StringComparer.Ordinal);
+ private readonly Dictionary numericInspectorFields =
+ new(StringComparer.Ordinal);
private EditorWorkspace? workspace;
public InspectorPaneControl()
@@ -33,11 +35,12 @@ private void RebuildInspector()
{
EditorWorkspace currentWorkspace = GetWorkspace();
inspectorFields.Clear();
+ numericInspectorFields.Clear();
InspectorFieldsPanel.Children.Clear();
TrackEditorDocument? document = currentWorkspace.ActiveDocument;
EditorSelection? selection = currentWorkspace.CurrentSelection;
- if (document?.Graph is null || document.GraphCompileResult is null || selection is null)
+ if (document?.Graph is null || selection is null)
{
InspectorTitleText.Text = "Nothing selected";
AddInspectorNote("Select a section, Math Plot station, or viewport sample.");
@@ -57,11 +60,11 @@ private void RebuildInspector()
break;
case EditorSelectionKind.BankingKey:
InspectorTitleText.Text = "Banking key";
- AddInspectorNote("Banking-key editing is outside the M157 graph-authoring vertical slice.");
+ AddInspectorNote("Banking-key editing is outside the M166 authoring scope.");
break;
case EditorSelectionKind.ControlPoint:
InspectorTitleText.Text = "Spatial control point";
- AddInspectorNote("Control-point editing is outside the M157 graph-authoring vertical slice.");
+ AddInspectorNote("Control-point editing is outside the M166 authoring scope.");
break;
}
}
@@ -100,18 +103,19 @@ private void BuildTrackInspector(TrackEditorDocument document)
{
AddInspectorField(
"heartlineNormal",
- "Heartline normal",
- heartline.Value.NormalOffsetMeters.ToString("G17", CultureInfo.InvariantCulture),
+ "Heartline normal (m)",
+ heartline.Value.NormalOffsetMeters.ToString("0.######", CultureInfo.InvariantCulture),
editable: false);
AddInspectorField(
"heartlineLateral",
- "Heartline lateral",
- heartline.Value.LateralOffsetMeters.ToString("G17", CultureInfo.InvariantCulture),
+ "Heartline lateral (m)",
+ heartline.Value.LateralOffsetMeters.ToString("0.######", CultureInfo.InvariantCulture),
editable: false);
}
- AddInspectorNote(
- "M157 edits section nodes through the authoring graph. Document metadata remains read-only.");
+ AddInspectorNote(document.IsEmpty
+ ? "Use Add in the Route pane to create the first geometric section."
+ : "Section edits compile atomically through the backend authoring graph.");
}
private void BuildGraphNodeInspector(
@@ -135,49 +139,69 @@ private void BuildGraphNodeInspector(
return;
}
- GeometricSectionDefinition section = node.Section;
+ GeometricSectionDefinition section =
+ (GeometricSectionDefinition)node.Section;
+ bool supportsParameterEditing =
+ section is StraightSectionDefinition ||
+ section is ConstantCurvatureSectionDefinition ||
+ section is CurvatureTransitionSectionDefinition;
InspectorTitleText.Text = "Section: " + section.Id;
AddInspectorField("id", "Section ID", section.Id, editable: false);
AddInspectorField("kind", "Section type", DescribeSectionKind(section), editable: false);
- AddInspectorField(
- "sectionLength",
- "Length (m)",
- section.Length.ToString("G17", CultureInfo.InvariantCulture),
- editable: false);
- AddInspectorField(
- "rollDegrees",
- "Section roll (deg)",
- (section.RollRadians * 180.0 / System.Math.PI).ToString("G17", CultureInfo.InvariantCulture),
- editable: false);
+ if (supportsParameterEditing)
+ {
+ AddInspectorNumericField(
+ "sectionLength",
+ "Length (m)",
+ section.Length,
+ AuthoringNumericParameterKind.LengthMeters);
+ AddInspectorNumericField(
+ "rollDegrees",
+ "Section roll (deg)",
+ section.RollRadians * 180.0 / System.Math.PI,
+ AuthoringNumericParameterKind.RollDegrees);
+ }
+ else
+ {
+ AddInspectorField(
+ "sectionLength",
+ "Length (m)",
+ section.Length.ToString("0.######", CultureInfo.InvariantCulture),
+ editable: false);
+ AddInspectorField(
+ "rollDegrees",
+ "Section roll (deg)",
+ (section.RollRadians * 180.0 / System.Math.PI).ToString(
+ "0.###",
+ CultureInfo.InvariantCulture),
+ editable: false);
+ }
if (section is ConstantCurvatureSectionDefinition arc)
{
- AddInspectorField(
+ AddInspectorNumericField(
"radius",
"Signed radius (m)",
- arc.Radius.ToString("G17", CultureInfo.InvariantCulture));
- AddInspectorNote(
- "Changing signed radius recompiles the complete graph through the existing backend pipeline.");
- AddApplyButton("Apply radius", () => ApplyRadiusInspector(node.Id));
+ arc.Radius,
+ AuthoringNumericParameterKind.SignedRadiusMeters);
}
else if (section is CurvatureTransitionSectionDefinition transition)
{
- AddInspectorField(
+ AddInspectorNumericField(
"startCurvature",
- "Start curvature",
- transition.StartCurvature.ToString("G17", CultureInfo.InvariantCulture),
- editable: false);
- AddInspectorField(
+ "Start curvature (1/m)",
+ transition.StartCurvature,
+ AuthoringNumericParameterKind.CurvaturePerMeter);
+ AddInspectorNumericField(
"endCurvature",
- "End curvature",
- transition.EndCurvature.ToString("G17", CultureInfo.InvariantCulture),
- editable: false);
+ "End curvature (1/m)",
+ transition.EndCurvature,
+ AuthoringNumericParameterKind.CurvaturePerMeter);
AddInspectorField(
"interpolation",
"Interpolation",
transition.InterpolationMode.ToString(),
editable: false);
- AddInspectorNote("Transition editing is intentionally deferred beyond M157.");
}
else if (section is SpatialSectionDefinition spatial)
{
@@ -191,12 +215,18 @@ private void BuildGraphNodeInspector(
"Control points",
spatial.ControlPoints.Count.ToString(CultureInfo.InvariantCulture),
editable: false);
- AddInspectorNote("Spatial control-point editing is intentionally deferred beyond M157.");
+ AddInspectorNote("Spatial control-point editing is intentionally deferred beyond M166.");
}
else
+ {
+ AddInspectorNote("Straight sections use the common length and roll parameters.");
+ }
+
+ if (supportsParameterEditing)
{
AddInspectorNote(
- "Straight-node length editing is deferred because authored banking shares the total station domain.");
+ "Applying parameters validates and recompiles the complete immutable route.");
+ AddApplyButton("Apply section", () => ApplySectionInspector(node.Id));
}
int sectionIndex = currentWorkspace.GraphNodes
@@ -281,12 +311,16 @@ private void AddCanonicalEngineeringFields(
AddInspectorField(
keyPrefix + "SectionType",
"Section type",
- sectionNode is null ? "Unavailable" : DescribeSectionKind(sectionNode.Section),
+ sectionNode?.Section is GeometricSectionDefinition geometricSection
+ ? DescribeSectionKind(geometricSection)
+ : "Unavailable",
editable: false);
AddInspectorField(
keyPrefix + "SectionLength",
"Section length",
- sectionNode is null ? "Unavailable" : $"{sectionNode.Section.Length:F3} m",
+ sectionNode?.Section is GeometricSectionDefinition sectionDefinition
+ ? $"{sectionDefinition.Length:F3} m"
+ : "Unavailable",
editable: false);
}
@@ -336,7 +370,7 @@ private static string FormatPlotValue(double? value, string unit, int digits = 3
: "Unavailable";
}
- private void AddInspectorField(string key, string label, string value, bool editable = true)
+ private void AddInspectorField(string key, string label, string value, bool editable = false)
{
var grid = new Grid
{
@@ -363,6 +397,34 @@ private void AddInspectorField(string key, string label, string value, bool edit
inspectorFields[key] = field;
}
+ private void AddInspectorNumericField(
+ string key,
+ string label,
+ double value,
+ AuthoringNumericParameterKind kind)
+ {
+ var grid = new Grid
+ {
+ ColumnDefinitions = new ColumnDefinitions("118,*"),
+ ColumnSpacing = 8
+ };
+ var labelBlock = new TextBlock
+ {
+ Text = label,
+ VerticalAlignment = VerticalAlignment.Center,
+ Foreground = Brush.Parse("#8FA5B9"),
+ TextWrapping = TextWrapping.Wrap
+ };
+ NumericUpDown field = AuthoringNumericControls.Create(key, kind, value);
+ field.Classes.Add("inspectorField");
+
+ Grid.SetColumn(field, 1);
+ grid.Children.Add(labelBlock);
+ grid.Children.Add(field);
+ InspectorFieldsPanel.Children.Add(grid);
+ numericInspectorFields[key] = field;
+ }
+
private void AddInspectorNote(string text)
{
InspectorFieldsPanel.Children.Add(new TextBlock
@@ -386,26 +448,45 @@ private void AddApplyButton(string label, Action apply)
InspectorFieldsPanel.Children.Add(button);
}
- private void ApplyRadiusInspector(string nodeId)
+ private void ApplySectionInspector(string nodeId)
{
EditorWorkspace currentWorkspace = GetWorkspace();
try
{
- double radius = NumberField("radius");
- currentWorkspace.ApplyGraphEdit($"Edit {nodeId} radius", graph =>
+ double length = NumberField("sectionLength");
+ double rollRadians = NumberField("rollDegrees") * System.Math.PI / 180.0;
+ currentWorkspace.ApplyGraphEdit($"Edit section {nodeId}", graph =>
{
TrackAuthoringGraphNode node = graph.Nodes.Single(candidate =>
string.Equals(candidate.Id, nodeId, StringComparison.Ordinal));
- ConstantCurvatureSectionDefinition arc =
- node.Section as ConstantCurvatureSectionDefinition ??
- throw new InvalidOperationException(
- $"Graph node ID '{nodeId}' is not a constant-curvature section.");
- var replacement = new ConstantCurvatureSectionDefinition(
- arc.Id,
- arc.Length,
- radius,
- arc.RollRadians);
- return graph.WithSection(nodeId, replacement);
+ TrackAuthoringSectionDefinition replacement = node.Section switch
+ {
+ StraightSectionDefinition straight =>
+ new StraightSectionDefinition(
+ straight.Id,
+ length,
+ rollRadians),
+ ConstantCurvatureSectionDefinition arc =>
+ new ConstantCurvatureSectionDefinition(
+ arc.Id,
+ length,
+ NumberField("radius"),
+ rollRadians),
+ CurvatureTransitionSectionDefinition transition =>
+ new CurvatureTransitionSectionDefinition(
+ transition.Id,
+ length,
+ NumberField("startCurvature"),
+ NumberField("endCurvature"),
+ transition.InterpolationMode,
+ rollRadians),
+ _ => throw new NotSupportedException(
+ $"Section type '{node.TypeId}' does not have an M166 Inspector editor.")
+ };
+ return TrackAuthoringGraphOperations.Replace(
+ graph,
+ nodeId,
+ replacement);
});
}
catch (Exception exception) when (exception is FormatException || exception is OverflowException)
@@ -414,24 +495,12 @@ node.Section as ConstantCurvatureSectionDefinition ??
}
}
- private string Field(string key)
- {
- return inspectorFields.TryGetValue(key, out TextBox? field)
- ? field.Text ?? string.Empty
- : throw new InvalidOperationException($"Inspector field '{key}' is unavailable.");
- }
-
private double NumberField(string key)
{
- string value = Field(key);
- if (!double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out double number) ||
- double.IsNaN(number) ||
- double.IsInfinity(number))
- {
- throw new FormatException($"'{value}' is not a finite invariant-culture number for {key}.");
- }
-
- return number;
+ return numericInspectorFields.TryGetValue(key, out NumericUpDown? field)
+ ? AuthoringNumericControls.ReadFiniteDouble(field, key)
+ : throw new InvalidOperationException(
+ $"Inspector numeric field '{key}' is unavailable.");
}
private EditorWorkspace GetWorkspace()
diff --git a/Quantum.Editor.Avalonia/Controls/RoutePaneControl.axaml b/Quantum.Editor.Avalonia/Controls/RoutePaneControl.axaml
index 9aff17f..b8df607 100644
--- a/Quantum.Editor.Avalonia/Controls/RoutePaneControl.axaml
+++ b/Quantum.Editor.Avalonia/Controls/RoutePaneControl.axaml
@@ -25,7 +25,7 @@
Background="#151C24"
BorderBrush="#2B3948"
BorderThickness="0,0,1,0">
-
+
@@ -34,9 +34,37 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Quantum.Editor.Avalonia/Controls/RoutePaneControl.axaml.cs b/Quantum.Editor.Avalonia/Controls/RoutePaneControl.axaml.cs
index f4a3a55..d31d091 100644
--- a/Quantum.Editor.Avalonia/Controls/RoutePaneControl.axaml.cs
+++ b/Quantum.Editor.Avalonia/Controls/RoutePaneControl.axaml.cs
@@ -24,6 +24,18 @@ public RoutePaneControl()
public event EventHandler? SectionPointerChanged;
+ public event EventHandler? AddSectionRequested;
+
+ public event EventHandler? InsertBeforeRequested;
+
+ public event EventHandler? InsertAfterRequested;
+
+ public event EventHandler? DeleteSectionRequested;
+
+ public event EventHandler? MoveUpRequested;
+
+ public event EventHandler? MoveDownRequested;
+
public string DocumentTitle
{
get => DocumentTitleText.Text ?? string.Empty;
@@ -173,6 +185,15 @@ private void UpdateNodeAppearance()
button.BorderBrush = Brush.Parse(
highlighted ? "#F4D35E" : selected ? "#59B5E8" : "#34495C");
}
+
+ EditorGraphNode? selectedNode = SelectedNode();
+ bool hasSelection = selectedNode != null;
+ InsertBeforeButton.IsEnabled = hasSelection;
+ InsertAfterButton.IsEnabled = hasSelection;
+ DeleteSectionButton.IsEnabled = hasSelection;
+ MoveUpButton.IsEnabled = selectedNode?.RouteIndex > 0;
+ MoveDownButton.IsEnabled =
+ selectedNode != null && selectedNode.RouteIndex < graphNodes.Count - 1;
}
private void OnGraphNodeClick(object? sender, RoutedEventArgs eventArgs)
@@ -195,4 +216,58 @@ private void OnGraphNodePointerExited(object? sender, PointerEventArgs eventArgs
{
SectionPointerChanged?.Invoke(this, new SectionPointerChangedEventArgs(null));
}
+
+ private void OnAddSectionClick(object? sender, RoutedEventArgs eventArgs)
+ {
+ AddSectionRequested?.Invoke(this, EventArgs.Empty);
+ }
+
+ private void OnInsertBeforeClick(object? sender, RoutedEventArgs eventArgs)
+ {
+ RaiseSelectedNode(InsertBeforeRequested);
+ }
+
+ private void OnInsertAfterClick(object? sender, RoutedEventArgs eventArgs)
+ {
+ RaiseSelectedNode(InsertAfterRequested);
+ }
+
+ private void OnDeleteSectionClick(object? sender, RoutedEventArgs eventArgs)
+ {
+ RaiseSelectedNode(DeleteSectionRequested);
+ }
+
+ private void OnMoveUpClick(object? sender, RoutedEventArgs eventArgs)
+ {
+ RaiseSelectedNode(MoveUpRequested);
+ }
+
+ private void OnMoveDownClick(object? sender, RoutedEventArgs eventArgs)
+ {
+ RaiseSelectedNode(MoveDownRequested);
+ }
+
+ private void RaiseSelectedNode(
+ EventHandler? handler)
+ {
+ EditorGraphNode? node = SelectedNode();
+ if (node != null)
+ {
+ handler?.Invoke(this, new GraphNodeSelectedEventArgs(node));
+ }
+ }
+
+ private EditorGraphNode? SelectedNode()
+ {
+ if (selection?.NodeId is string nodeId)
+ {
+ return graphNodes.FirstOrDefault(node =>
+ string.Equals(node.NodeId, nodeId, StringComparison.Ordinal));
+ }
+
+ return selection?.SectionIndex >= 0
+ ? graphNodes.FirstOrDefault(node =>
+ node.RouteIndex == selection.SectionIndex)
+ : null;
+ }
}
diff --git a/Quantum.Editor.Avalonia/Controls/SectionEditorDialog.cs b/Quantum.Editor.Avalonia/Controls/SectionEditorDialog.cs
new file mode 100644
index 0000000..054ca99
--- /dev/null
+++ b/Quantum.Editor.Avalonia/Controls/SectionEditorDialog.cs
@@ -0,0 +1,311 @@
+using Avalonia.Controls;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Quantum.Track.Authoring;
+
+namespace Quantum.Editor.Avalonia.Controls;
+
+///
+/// Presentation-only typed creator for the initial M166 geometric catalog.
+/// Backend constructors remain authoritative for validation.
+///
+public sealed class SectionEditorDialog : Window
+{
+ private readonly ComboBox typeSelector;
+ private readonly TextBox idField;
+ private readonly NumericUpDown lengthField;
+ private readonly NumericUpDown rollField;
+ private readonly NumericUpDown radiusField;
+ private readonly NumericUpDown startCurvatureField;
+ private readonly NumericUpDown endCurvatureField;
+ private readonly Control radiusRow;
+ private readonly Control startCurvatureRow;
+ private readonly Control endCurvatureRow;
+ private readonly TextBlock validationText;
+ private readonly Func suggestId;
+ private string lastSuggestedId;
+
+ public SectionEditorDialog(Func suggestId)
+ {
+ this.suggestId = suggestId ?? throw new ArgumentNullException(nameof(suggestId));
+
+ Title = "Add Geometric Section";
+ Width = 470;
+ Height = 500;
+ MinWidth = 420;
+ MinHeight = 450;
+ CanResize = true;
+ WindowStartupLocation = WindowStartupLocation.CenterOwner;
+
+ SectionChoice[] choices = CreateChoices();
+ typeSelector = new ComboBox
+ {
+ ItemsSource = choices,
+ SelectedIndex = 0,
+ HorizontalAlignment = HorizontalAlignment.Stretch
+ };
+ typeSelector.SelectionChanged += (_, _) => UpdateTypePresentation();
+
+ idField = CreateTextBox();
+ lengthField = AuthoringNumericControls.Create(
+ "length",
+ AuthoringNumericParameterKind.LengthMeters,
+ 10.0);
+ rollField = AuthoringNumericControls.Create(
+ "rollDegrees",
+ AuthoringNumericParameterKind.RollDegrees,
+ 0.0);
+ radiusField = AuthoringNumericControls.Create(
+ "radius",
+ AuthoringNumericParameterKind.SignedRadiusMeters,
+ 25.0);
+ startCurvatureField = AuthoringNumericControls.Create(
+ "startCurvature",
+ AuthoringNumericParameterKind.CurvaturePerMeter,
+ 0.0);
+ endCurvatureField = AuthoringNumericControls.Create(
+ "endCurvature",
+ AuthoringNumericParameterKind.CurvaturePerMeter,
+ 0.04);
+
+ lastSuggestedId = suggestId(TrackAuthoringSectionTypeIds.Straight);
+ idField.Text = lastSuggestedId;
+
+ var fields = new StackPanel
+ {
+ Spacing = 10,
+ Children =
+ {
+ CreateFieldRow("Family", ReadOnlyText("Geometry")),
+ CreateFieldRow("Section type", typeSelector),
+ CreateFieldRow("Section ID", idField),
+ CreateFieldRow("Length (m)", lengthField),
+ CreateFieldRow("Roll (deg)", rollField)
+ }
+ };
+ radiusRow = CreateFieldRow("Signed radius (m)", radiusField);
+ startCurvatureRow = CreateFieldRow("Start curvature (1/m)", startCurvatureField);
+ endCurvatureRow = CreateFieldRow("End curvature (1/m)", endCurvatureField);
+ fields.Children.Add(radiusRow);
+ fields.Children.Add(startCurvatureRow);
+ fields.Children.Add(endCurvatureRow);
+
+ validationText = new TextBlock
+ {
+ Foreground = Brush.Parse("#E98B8B"),
+ TextWrapping = TextWrapping.Wrap,
+ MinHeight = 44
+ };
+
+ var addButton = new Button
+ {
+ Content = "Add and Compile",
+ MinWidth = 135
+ };
+ addButton.Click += (_, _) => TryAccept();
+ var cancelButton = new Button
+ {
+ Content = "Cancel",
+ MinWidth = 90
+ };
+ cancelButton.Click += (_, _) => Close(null);
+
+ Content = new Grid
+ {
+ RowDefinitions = new RowDefinitions("*,Auto"),
+ Children =
+ {
+ new ScrollViewer
+ {
+ Content = new StackPanel
+ {
+ Margin = new global::Avalonia.Thickness(20),
+ Spacing = 14,
+ Children =
+ {
+ new TextBlock
+ {
+ Text = "Create a typed backend section definition. " +
+ "The complete route is validated and compiled before it is committed.",
+ Foreground = Brush.Parse("#8FA5B9"),
+ TextWrapping = TextWrapping.Wrap
+ },
+ fields,
+ validationText
+ }
+ }
+ },
+ CreateButtonBar(cancelButton, addButton)
+ }
+ };
+
+ UpdateTypePresentation();
+ }
+
+ private SectionChoice SelectedChoice =>
+ typeSelector.SelectedItem as SectionChoice ??
+ throw new InvalidOperationException("A section type must be selected.");
+
+ private void UpdateTypePresentation()
+ {
+ if (typeSelector.SelectedItem is not SectionChoice choice)
+ {
+ return;
+ }
+
+ bool arc = choice.TypeId == TrackAuthoringSectionTypeIds.ConstantCurvature;
+ bool transition = choice.TypeId == TrackAuthoringSectionTypeIds.CurvatureTransition;
+ radiusRow.IsVisible = arc;
+ startCurvatureRow.IsVisible = transition;
+ endCurvatureRow.IsVisible = transition;
+
+ string currentId = idField.Text ?? string.Empty;
+ string replacementSuggestion = suggestId(choice.TypeId);
+ if (string.IsNullOrWhiteSpace(currentId) ||
+ string.Equals(currentId, lastSuggestedId, StringComparison.Ordinal))
+ {
+ idField.Text = replacementSuggestion;
+ }
+
+ lastSuggestedId = replacementSuggestion;
+ validationText.Text = string.Empty;
+ }
+
+ private void TryAccept()
+ {
+ try
+ {
+ string id = idField.Text ?? string.Empty;
+ double length = Number(lengthField, "length");
+ double rollRadians = Number(rollField, "roll") * System.Math.PI / 180.0;
+ TrackAuthoringSectionDefinition section = SelectedChoice.TypeId switch
+ {
+ TrackAuthoringSectionTypeIds.Straight =>
+ new StraightSectionDefinition(id, length, rollRadians),
+ TrackAuthoringSectionTypeIds.ConstantCurvature =>
+ new ConstantCurvatureSectionDefinition(
+ id,
+ length,
+ Number(radiusField, "signed radius"),
+ rollRadians),
+ TrackAuthoringSectionTypeIds.CurvatureTransition =>
+ new CurvatureTransitionSectionDefinition(
+ id,
+ length,
+ Number(startCurvatureField, "start curvature"),
+ Number(endCurvatureField, "end curvature"),
+ CurvatureTransitionInterpolationMode.Linear,
+ rollRadians),
+ _ => throw new NotSupportedException(
+ $"Section type '{SelectedChoice.TypeId}' is not in the M166 creation catalog.")
+ };
+
+ Close(section);
+ }
+ catch (Exception exception) when (
+ exception is ArgumentException ||
+ exception is InvalidOperationException ||
+ exception is NotSupportedException ||
+ exception is FormatException ||
+ exception is OverflowException)
+ {
+ validationText.Text = exception.Message.Replace(Environment.NewLine, " ");
+ }
+ }
+
+ private static SectionChoice[] CreateChoices()
+ {
+ IReadOnlyList authorableTypeIds = new[]
+ {
+ TrackAuthoringSectionTypeIds.Straight,
+ TrackAuthoringSectionTypeIds.ConstantCurvature,
+ TrackAuthoringSectionTypeIds.CurvatureTransition
+ };
+ return TrackAuthoringSectionCatalog.Types
+ .Where(type =>
+ type.Family == TrackAuthoringSectionFamily.Geometry &&
+ authorableTypeIds.Contains(type.TypeId))
+ .Select(type => new SectionChoice(type.TypeId, DisplayName(type.TypeId)))
+ .ToArray();
+ }
+
+ private static string DisplayName(string typeId)
+ {
+ return typeId switch
+ {
+ TrackAuthoringSectionTypeIds.Straight => "Straight",
+ TrackAuthoringSectionTypeIds.ConstantCurvature => "Constant Curvature",
+ TrackAuthoringSectionTypeIds.CurvatureTransition => "Curvature Transition",
+ _ => typeId
+ };
+ }
+
+ private static Grid CreateFieldRow(string label, Control field)
+ {
+ var row = new Grid
+ {
+ ColumnDefinitions = new ColumnDefinitions("145,*"),
+ ColumnSpacing = 10
+ };
+ row.Children.Add(new TextBlock
+ {
+ Text = label,
+ VerticalAlignment = VerticalAlignment.Center,
+ Foreground = Brush.Parse("#8FA5B9"),
+ TextWrapping = TextWrapping.Wrap
+ });
+ Grid.SetColumn(field, 1);
+ row.Children.Add(field);
+ return row;
+ }
+
+ private static Border CreateButtonBar(Button cancelButton, Button addButton)
+ {
+ var buttons = new StackPanel
+ {
+ Orientation = Orientation.Horizontal,
+ HorizontalAlignment = HorizontalAlignment.Right,
+ Spacing = 8,
+ Children = { cancelButton, addButton }
+ };
+ var border = new Border
+ {
+ Padding = new global::Avalonia.Thickness(20, 12),
+ BorderBrush = Brush.Parse("#2B3948"),
+ BorderThickness = new global::Avalonia.Thickness(0, 1, 0, 0),
+ Child = buttons
+ };
+ Grid.SetRow(border, 1);
+ return border;
+ }
+
+ private static TextBox CreateTextBox(string text = "")
+ {
+ return new TextBox { Text = text, MinHeight = 30 };
+ }
+
+ private static TextBox ReadOnlyText(string text)
+ {
+ return new TextBox { Text = text, IsReadOnly = true, MinHeight = 30 };
+ }
+
+ private static double Number(NumericUpDown field, string label)
+ {
+ return AuthoringNumericControls.ReadFiniteDouble(field, label);
+ }
+
+ private sealed class SectionChoice
+ {
+ public SectionChoice(string typeId, string displayName)
+ {
+ TypeId = typeId;
+ DisplayName = displayName;
+ }
+
+ public string TypeId { get; }
+
+ public string DisplayName { get; }
+
+ public override string ToString() => DisplayName;
+ }
+}
diff --git a/Quantum.Editor.Avalonia/MainWindow.axaml.cs b/Quantum.Editor.Avalonia/MainWindow.axaml.cs
index 0bacbba..6bac08d 100644
--- a/Quantum.Editor.Avalonia/MainWindow.axaml.cs
+++ b/Quantum.Editor.Avalonia/MainWindow.axaml.cs
@@ -13,6 +13,7 @@
using Quantum.Editor.Avalonia.Services.Documents;
using Quantum.Editor.Avalonia.Services.Trains;
using Quantum.Editor.Avalonia.Services.Workspaces;
+using Quantum.Track.Authoring;
namespace Quantum.Editor.Avalonia;
@@ -146,6 +147,12 @@ private static EditorWorkspace CreateWorkspace()
private void WirePaneInteractions()
{
RoutePane.NodeSelected += OnGraphNodeSelected;
+ RoutePane.AddSectionRequested += OnAddSectionRequested;
+ RoutePane.InsertBeforeRequested += OnInsertBeforeRequested;
+ RoutePane.InsertAfterRequested += OnInsertAfterRequested;
+ RoutePane.DeleteSectionRequested += OnDeleteSectionRequested;
+ RoutePane.MoveUpRequested += OnMoveUpRequested;
+ RoutePane.MoveDownRequested += OnMoveDownRequested;
RoutePane.SectionPointerChanged += OnSectionPointerChanged;
ViewportPane.SectionPointerChanged += OnSectionPointerChanged;
ViewportPane.SampleSelected += OnViewportSampleSelected;
@@ -317,6 +324,10 @@ private void UpdateWorkspaceView()
RoutePane.DocumentPath = document?.FilePath ?? "Unsaved Track Layout Package V2";
RoutePane.GraphNodes = workspace.GraphNodes;
RoutePane.Selection = selection;
+ bool canSaveTrack = document?.CanSave == true;
+ SaveButton.IsEnabled = canSaveTrack;
+ SaveTrackMenuItem.IsEnabled = canSaveTrack;
+ SaveTrackAsMenuItem.IsEnabled = canSaveTrack;
if (trackActive)
{
@@ -641,6 +652,90 @@ private void OnGraphNodeSelected(object? sender, GraphNodeSelectedEventArgs even
workspace.SetStatus("Selected section " + node.NodeId + ".");
}
+ private async void OnAddSectionRequested(object? sender, EventArgs eventArgs)
+ {
+ TrackAuthoringSectionDefinition? section = await ShowSectionEditorAsync();
+ if (section != null)
+ {
+ workspace.AddSection(section);
+ }
+ }
+
+ private async void OnInsertBeforeRequested(
+ object? sender,
+ GraphNodeSelectedEventArgs eventArgs)
+ {
+ TrackAuthoringSectionDefinition? section = await ShowSectionEditorAsync();
+ if (section != null)
+ {
+ workspace.InsertSectionBefore(eventArgs.Node.NodeId, section);
+ }
+ }
+
+ private async void OnInsertAfterRequested(
+ object? sender,
+ GraphNodeSelectedEventArgs eventArgs)
+ {
+ TrackAuthoringSectionDefinition? section = await ShowSectionEditorAsync();
+ if (section != null)
+ {
+ workspace.InsertSectionAfter(eventArgs.Node.NodeId, section);
+ }
+ }
+
+ private void OnDeleteSectionRequested(
+ object? sender,
+ GraphNodeSelectedEventArgs eventArgs)
+ {
+ workspace.DeleteSection(eventArgs.Node.NodeId);
+ }
+
+ private void OnMoveUpRequested(
+ object? sender,
+ GraphNodeSelectedEventArgs eventArgs)
+ {
+ workspace.MoveSectionUp(eventArgs.Node.NodeId);
+ }
+
+ private void OnMoveDownRequested(
+ object? sender,
+ GraphNodeSelectedEventArgs eventArgs)
+ {
+ workspace.MoveSectionDown(eventArgs.Node.NodeId);
+ }
+
+ private Task ShowSectionEditorAsync()
+ {
+ var dialog = new SectionEditorDialog(SuggestSectionId);
+ return dialog.ShowDialog(this);
+ }
+
+ private string SuggestSectionId(string typeId)
+ {
+ string prefix = typeId switch
+ {
+ TrackAuthoringSectionTypeIds.Straight => "straight",
+ TrackAuthoringSectionTypeIds.ConstantCurvature => "curve",
+ TrackAuthoringSectionTypeIds.CurvatureTransition => "transition",
+ _ => "section"
+ };
+ var existingIds = new HashSet(
+ workspace.GraphNodes.Select(node => node.NodeId),
+ StringComparer.Ordinal);
+ if (!existingIds.Contains(prefix))
+ {
+ return prefix;
+ }
+
+ int suffix = 2;
+ while (existingIds.Contains(prefix + "-" + suffix.ToString(CultureInfo.InvariantCulture)))
+ {
+ suffix++;
+ }
+
+ return prefix + "-" + suffix.ToString(CultureInfo.InvariantCulture);
+ }
+
private async void OnWindowKeyDown(object? sender, KeyEventArgs eventArgs)
{
bool control = eventArgs.KeyModifiers.HasFlag(KeyModifiers.Control);
diff --git a/Quantum.Editor.Avalonia/Quantum.Editor.Avalonia.csproj b/Quantum.Editor.Avalonia/Quantum.Editor.Avalonia.csproj
index 630f89f..4af7597 100644
--- a/Quantum.Editor.Avalonia/Quantum.Editor.Avalonia.csproj
+++ b/Quantum.Editor.Avalonia/Quantum.Editor.Avalonia.csproj
@@ -25,4 +25,10 @@
+
+
+ <_Parameter1>Quantum.Tests
+
+
+
diff --git a/Quantum.Editor.Avalonia/Services/Documents/TrackEditorDocument.cs b/Quantum.Editor.Avalonia/Services/Documents/TrackEditorDocument.cs
index a6bf621..65d78e1 100644
--- a/Quantum.Editor.Avalonia/Services/Documents/TrackEditorDocument.cs
+++ b/Quantum.Editor.Avalonia/Services/Documents/TrackEditorDocument.cs
@@ -22,17 +22,17 @@ public TrackEditorDocument(TrackDocument trackDocument, string displayName)
private TrackEditorDocument(
TrackAuthoringGraph graph,
TrackLayoutPackageV2GraphAncillaryState ancillaryState,
- TrackAuthoringGraphCompileResult graphCompileResult,
+ TrackAuthoringGraphCompileResult? graphCompileResult,
string displayName,
string? filePath)
- : this(graphCompileResult.Compilation!.Document, displayName)
+ : this(graphCompileResult?.Compilation?.Document ?? new TrackDocument(), displayName)
{
this.graph = graph;
this.ancillaryState = ancillaryState;
GraphCompileResult = graphCompileResult;
- Compilation = graphCompileResult.Compilation;
+ Compilation = graphCompileResult?.Compilation;
FilePath = filePath;
- cleanPackageJson = CapturePackageJson();
+ cleanPackageJson = CanSave ? CapturePackageJson() : null;
}
public event EventHandler? Changed;
@@ -58,15 +58,43 @@ private TrackEditorDocument(
/// mutable editor-owned state.
///
public TrackLayoutPackageV2Dto? Package =>
- graph is null || ancillaryState is null
+ !CanSave
? null
- : ExportPackage(graph, ancillaryState);
+ : ExportPackage(graph!, ancillaryState!);
public TrackAuthoringCompilation? Compilation { get; private set; }
public string? FilePath { get; private set; }
- public bool CanSave => graph != null && ancillaryState != null;
+ public bool CanSave =>
+ graph != null &&
+ graph.Nodes.Count != 0 &&
+ ancillaryState != null &&
+ Compilation != null;
+
+ public bool IsEmpty => graph?.Nodes.Count == 0;
+
+ public static TrackEditorDocument CreateEmpty(
+ string displayName,
+ string? sourceName = null)
+ {
+ var graph = new TrackAuthoringGraph(
+ Array.Empty(),
+ Array.Empty());
+ var ancillaryState = new TrackLayoutPackageV2GraphAncillaryState(
+ TrackLayoutPackageV2Dto.ContractName,
+ TrackLayoutPackageV2Dto.ContractVersion,
+ "meters",
+ sourceName,
+ layoutId: null,
+ heartlineOffset: null);
+ return new TrackEditorDocument(
+ graph,
+ ancillaryState,
+ graphCompileResult: null,
+ displayName,
+ filePath: null);
+ }
public static TrackEditorDocument Create(
TrackLayoutPackageV2Dto package,
@@ -103,13 +131,13 @@ public static TrackEditorDocument Create(
public string CapturePackageJson()
{
- if (graph is null || ancillaryState is null)
+ if (!CanSave)
{
throw new InvalidOperationException(
- "This editor document does not have a persistable Track Layout Package V2 graph model.");
+ "A non-empty successfully compiled route is required before saving Track Layout Package V2.");
}
- return SerializePackage(graph, ancillaryState);
+ return SerializePackage(graph!, ancillaryState!);
}
internal string CapturePackageJson(TrackAuthoringGraph candidateGraph)
@@ -133,6 +161,22 @@ public void ReplaceGraph(TrackAuthoringGraph candidateGraph)
"This editor document does not have an editable authoring graph.");
}
+ TrackAuthoringGraphRouteResult route =
+ TrackAuthoringGraphRouteValidator.Validate(candidateGraph);
+ if (!route.Success)
+ {
+ throw new InvalidOperationException(FormatGraphDiagnostics(
+ "The candidate authoring route was rejected",
+ route.Diagnostics));
+ }
+
+ if (candidateGraph.Nodes.Count == 0)
+ {
+ bool emptyIsDirty = explicitlyDirty || cleanPackageJson != null;
+ CommitEmptyGraph(candidateGraph, emptyIsDirty);
+ return;
+ }
+
TrackAuthoringGraphCompileResult candidateCompilation =
TrackAuthoringGraphCompiler.Compile(candidateGraph);
if (!candidateCompilation.Success || candidateCompilation.Compilation is null)
@@ -241,6 +285,18 @@ private void CommitCompiledGraph(
Changed?.Invoke(this, EventArgs.Empty);
}
+ private void CommitEmptyGraph(
+ TrackAuthoringGraph candidateGraph,
+ bool candidateIsDirty)
+ {
+ graph = candidateGraph;
+ GraphCompileResult = null;
+ Compilation = null;
+ TrackDocument = new TrackDocument();
+ IsDirty = candidateIsDirty;
+ Changed?.Invoke(this, EventArgs.Empty);
+ }
+
private static string SerializePackage(
TrackAuthoringGraph sourceGraph,
TrackLayoutPackageV2GraphAncillaryState sourceAncillaryState)
diff --git a/Quantum.Editor.Avalonia/Services/EditorWorkspace.cs b/Quantum.Editor.Avalonia/Services/EditorWorkspace.cs
index 562a4a9..f45da33 100644
--- a/Quantum.Editor.Avalonia/Services/EditorWorkspace.cs
+++ b/Quantum.Editor.Avalonia/Services/EditorWorkspace.cs
@@ -82,7 +82,7 @@ public EditorWorkspace(
public IReadOnlyList GraphNodes => graphNodes;
///
- /// Preserved compatibility projection for non-graph callers. The Avalonia M157
+ /// Preserved compatibility projection for non-graph callers. The Avalonia
/// surface uses .
///
public IReadOnlyList OutlinerNodes => outlinerNodes;
@@ -105,11 +105,11 @@ public EditorWorkspace(
public TrackEditorDocument NewDocument()
{
- TrackEditorDocument document = TrackEditorDocument.Create(
- TrackPackageFactory.CreateShowcasePackage(),
+ TrackEditorDocument document = TrackEditorDocument.CreateEmpty(
+ "Untitled Layout",
"Untitled Layout");
document.MarkDirty();
- ActivateDocument(document, "Created a new graph-authoring document from the showcase layout.");
+ ActivateDocument(document, "Created a new empty track-authoring document.");
return document;
}
@@ -163,23 +163,37 @@ exception is InvalidOperationException ||
return false;
}
- TrackAuthoringGraphCompileResult candidateCompilation =
- TrackAuthoringGraphCompiler.Compile(candidateGraph);
- if (!candidateCompilation.Success || candidateCompilation.Compilation is null)
+ TrackAuthoringGraphRouteResult route =
+ TrackAuthoringGraphRouteValidator.Validate(candidateGraph);
+ if (!route.Success)
{
- SetStatus("Edit rejected: " + FormatGraphDiagnostics(candidateCompilation.Diagnostics));
+ SetStatus("Edit rejected: " + FormatGraphDiagnostics(route.Diagnostics));
return false;
}
- try
+ if (candidateGraph.Nodes.Count != 0)
{
- string beforeJson = document.CapturePackageJson();
- string afterJson = document.CapturePackageJson(candidateGraph);
- if (string.Equals(beforeJson, afterJson, StringComparison.Ordinal))
+ TrackAuthoringGraphCompileResult candidateCompilation =
+ TrackAuthoringGraphCompiler.Compile(candidateGraph);
+ if (!candidateCompilation.Success || candidateCompilation.Compilation is null)
{
- SetStatus("No graph values changed.");
+ SetStatus("Edit rejected: " + FormatGraphDiagnostics(candidateCompilation.Diagnostics));
return false;
}
+ }
+
+ try
+ {
+ if (beforeGraph.Nodes.Count != 0 && candidateGraph.Nodes.Count != 0)
+ {
+ string beforeJson = document.CapturePackageJson();
+ string afterJson = document.CapturePackageJson(candidateGraph);
+ if (string.Equals(beforeJson, afterJson, StringComparison.Ordinal))
+ {
+ SetStatus("No graph values changed.");
+ return false;
+ }
+ }
UndoRedo.Execute(new TrackGraphSnapshotOperation(
description,
@@ -199,9 +213,153 @@ exception is ArgumentException ||
}
}
+ public bool AddSection(TrackAuthoringSectionDefinition section)
+ {
+ ArgumentNullException.ThrowIfNull(section);
+ bool applied = ApplyGraphEdit(
+ $"Add section {section.Id}",
+ graph => TrackAuthoringGraphOperations.Append(graph, section));
+ if (applied)
+ {
+ SelectGraphNode(section.Id);
+ }
+
+ return applied;
+ }
+
+ public bool InsertSectionBefore(
+ string anchorNodeId,
+ TrackAuthoringSectionDefinition section)
+ {
+ ArgumentNullException.ThrowIfNull(section);
+ bool applied = ApplyGraphEdit(
+ $"Insert section {section.Id} before {anchorNodeId}",
+ graph => TrackAuthoringGraphOperations.InsertBefore(
+ graph,
+ anchorNodeId,
+ section));
+ if (applied)
+ {
+ SelectGraphNode(section.Id);
+ }
+
+ return applied;
+ }
+
+ public bool InsertSectionAfter(
+ string anchorNodeId,
+ TrackAuthoringSectionDefinition section)
+ {
+ ArgumentNullException.ThrowIfNull(section);
+ bool applied = ApplyGraphEdit(
+ $"Insert section {section.Id} after {anchorNodeId}",
+ graph => TrackAuthoringGraphOperations.InsertAfter(
+ graph,
+ anchorNodeId,
+ section));
+ if (applied)
+ {
+ SelectGraphNode(section.Id);
+ }
+
+ return applied;
+ }
+
+ public bool DeleteSection(string nodeId)
+ {
+ TrackAuthoringGraph? graph = ActiveDocument?.Graph;
+ if (graph is null)
+ {
+ SetStatus("The active document does not have an editable authoring graph.");
+ return false;
+ }
+
+ TrackAuthoringGraphRouteResult route =
+ TrackAuthoringGraphRouteValidator.Validate(graph);
+ int deletedIndex = route.OrderedNodes
+ .Select((node, index) => (node, index))
+ .Where(item => string.Equals(item.node.Id, nodeId, StringComparison.Ordinal))
+ .Select(item => item.index)
+ .DefaultIfEmpty(-1)
+ .Single();
+
+ bool applied = ApplyGraphEdit(
+ $"Delete section {nodeId}",
+ candidate => TrackAuthoringGraphOperations.Delete(candidate, nodeId));
+ if (!applied)
+ {
+ return false;
+ }
+
+ IReadOnlyList remaining = GraphNodes;
+ if (remaining.Count == 0)
+ {
+ Select(EditorSelection.Track);
+ }
+ else
+ {
+ int fallbackIndex = System.Math.Min(deletedIndex, remaining.Count - 1);
+ Select(remaining[fallbackIndex].Selection);
+ }
+
+ return true;
+ }
+
+ public bool MoveSectionUp(string nodeId)
+ {
+ int index = FindGraphNodeIndex(nodeId);
+ if (index <= 0)
+ {
+ SetStatus(index < 0
+ ? $"Section {nodeId} was not found."
+ : $"Section {nodeId} is already first.");
+ return false;
+ }
+
+ string previousNodeId = GraphNodes[index - 1].NodeId;
+ bool applied = ApplyGraphEdit(
+ $"Move section {nodeId} up",
+ graph => TrackAuthoringGraphOperations.MoveBefore(
+ graph,
+ nodeId,
+ previousNodeId));
+ if (applied)
+ {
+ SelectGraphNode(nodeId);
+ }
+
+ return applied;
+ }
+
+ public bool MoveSectionDown(string nodeId)
+ {
+ int index = FindGraphNodeIndex(nodeId);
+ if (index < 0 || index >= GraphNodes.Count - 1)
+ {
+ SetStatus(index < 0
+ ? $"Section {nodeId} was not found."
+ : $"Section {nodeId} is already last.");
+ return false;
+ }
+
+ string nextNodeId = GraphNodes[index + 1].NodeId;
+ bool applied = ApplyGraphEdit(
+ $"Move section {nodeId} down",
+ graph => TrackAuthoringGraphOperations.MoveAfter(
+ graph,
+ nodeId,
+ nextNodeId));
+ if (applied)
+ {
+ SelectGraphNode(nodeId);
+ }
+
+ return applied;
+ }
+
///
/// Compatibility bridge for existing callers. Package edits are re-imported as a
- /// candidate graph and enter history only as immutable graph snapshots. M157 does
+ /// candidate graph and enter history only as immutable graph snapshots. This path does
/// not permit this bridge to change non-graph ancillary state.
///
public bool ApplyPackageEdit(
@@ -231,7 +389,7 @@ public bool ApplyPackageEdit(
if (!AncillaryStateEquals(document.AncillaryState, import.AncillaryState))
{
- SetStatus("Edit rejected: M157 package compatibility edits cannot change metadata or heartline state.");
+ SetStatus("Edit rejected: package compatibility edits cannot change metadata or heartline state.");
return false;
}
@@ -476,16 +634,35 @@ private void RefreshDocumentProjection()
}
}
+ NormalizeSelectionAfterProjection();
WorkspaceChanged?.Invoke(this, EventArgs.Empty);
}
+ private void NormalizeSelectionAfterProjection()
+ {
+ EditorSelection? selection = CurrentSelection;
+ if (selection?.Kind != EditorSelectionKind.Section || selection.NodeId is null)
+ {
+ return;
+ }
+
+ if (GraphNodes.Any(node =>
+ string.Equals(node.NodeId, selection.NodeId, StringComparison.Ordinal)))
+ {
+ return;
+ }
+
+ Select(EditorSelection.Track);
+ }
+
private static IReadOnlyList BuildGraphNodes(
IReadOnlyList orderedNodes)
{
var result = new EditorGraphNode[orderedNodes.Count];
for (int routeIndex = 0; routeIndex < orderedNodes.Count; routeIndex++)
{
- GeometricSectionDefinition section = orderedNodes[routeIndex].Section;
+ GeometricSectionDefinition section =
+ (GeometricSectionDefinition)orderedNodes[routeIndex].Section;
result[routeIndex] = new EditorGraphNode(
section.Id,
routeIndex,
@@ -496,13 +673,36 @@ private static IReadOnlyList BuildGraphNodes(
return result;
}
+ private void SelectGraphNode(string nodeId)
+ {
+ int index = FindGraphNodeIndex(nodeId);
+ if (index >= 0)
+ {
+ Select(GraphNodes[index].Selection);
+ }
+ }
+
+ private int FindGraphNodeIndex(string nodeId)
+ {
+ for (int i = 0; i < GraphNodes.Count; i++)
+ {
+ if (string.Equals(GraphNodes[i].NodeId, nodeId, StringComparison.Ordinal))
+ {
+ return i;
+ }
+ }
+
+ return -1;
+ }
+
private static IReadOnlyList BuildOutliner(TrackEditorDocument document)
{
IReadOnlyList route = document.GraphCompileResult!.OrderedNodes;
var sectionNodes = new List(route.Count);
for (int sectionIndex = 0; sectionIndex < route.Count; sectionIndex++)
{
- GeometricSectionDefinition section = route[sectionIndex].Section;
+ GeometricSectionDefinition section =
+ (GeometricSectionDefinition)route[sectionIndex].Section;
var controlPointNodes = new List();
if (section is SpatialSectionDefinition spatial)
{
diff --git a/Quantum.IO/TrackLayout/V2/TrackLayoutPackageV2GraphAdapter.cs b/Quantum.IO/TrackLayout/V2/TrackLayoutPackageV2GraphAdapter.cs
index f95bb72..f661611 100644
--- a/Quantum.IO/TrackLayout/V2/TrackLayoutPackageV2GraphAdapter.cs
+++ b/Quantum.IO/TrackLayout/V2/TrackLayoutPackageV2GraphAdapter.cs
@@ -176,7 +176,8 @@ public static TrackLayoutPackageV2GraphExportResult Export(
var sections = new TrackLayoutSectionV2Dto[graphCompilation.OrderedNodes.Count];
for (int i = 0; i < graphCompilation.OrderedNodes.Count; i++)
{
- sections[i] = MapSection(graphCompilation.OrderedNodes[i].Section);
+ sections[i] = MapSection(
+ (GeometricSectionDefinition)graphCompilation.OrderedNodes[i].Section);
}
var dto = new TrackLayoutPackageV2Dto
diff --git a/Quantum.Tests/Editor/AuthoringNumericControlsTests.cs b/Quantum.Tests/Editor/AuthoringNumericControlsTests.cs
new file mode 100644
index 0000000..cfcda26
--- /dev/null
+++ b/Quantum.Tests/Editor/AuthoringNumericControlsTests.cs
@@ -0,0 +1,170 @@
+using System.Globalization;
+using System.Reflection;
+using Avalonia.Controls;
+using Quantum.Editor.Avalonia.Controls;
+
+namespace Quantum.Tests;
+
+public sealed class AuthoringNumericControlsTests
+{
+ [Fact]
+ public void Profiles_ExposeExpectedBoundsStepsPrecisionAndSpinnerControls()
+ {
+ NumericUpDown length = AuthoringNumericControls.Create(
+ "length",
+ AuthoringNumericParameterKind.LengthMeters,
+ 10.0);
+ NumericUpDown roll = AuthoringNumericControls.Create(
+ "roll",
+ AuthoringNumericParameterKind.RollDegrees,
+ 0.0);
+ NumericUpDown radius = AuthoringNumericControls.Create(
+ "radius",
+ AuthoringNumericParameterKind.SignedRadiusMeters,
+ 25.0);
+ NumericUpDown curvature = AuthoringNumericControls.Create(
+ "curvature",
+ AuthoringNumericParameterKind.CurvaturePerMeter,
+ 0.04);
+
+ Assert.Equal(0.0m, length.Minimum);
+ Assert.Equal(1_000_000.0m, length.Maximum);
+ Assert.Equal(1.0m, length.Increment);
+ Assert.Equal("0.######", length.FormatString);
+
+ Assert.Equal(-36_000.0m, roll.Minimum);
+ Assert.Equal(36_000.0m, roll.Maximum);
+ Assert.Equal(1.0m, roll.Increment);
+ Assert.Equal("0.###", roll.FormatString);
+
+ Assert.Equal(-1_000_000.0m, radius.Minimum);
+ Assert.Equal(1_000_000.0m, radius.Maximum);
+ Assert.Equal(1.0m, radius.Increment);
+ Assert.Equal("0.######", radius.FormatString);
+
+ Assert.Equal(-100.0m, curvature.Minimum);
+ Assert.Equal(100.0m, curvature.Maximum);
+ Assert.Equal(0.001m, curvature.Increment);
+ Assert.Equal("0.########", curvature.FormatString);
+
+ Assert.All(
+ new[] { length, roll, radius, curvature },
+ field =>
+ {
+ Assert.True(field.AllowSpin);
+ Assert.True(field.ShowButtonSpinner);
+ Assert.False(field.ClipValueToMinMax);
+ Assert.Same(NumberFormatInfo.InvariantInfo, field.NumberFormat);
+ Assert.Equal(NumberStyles.Float, field.ParsingNumberStyle);
+ });
+ }
+
+ [Fact]
+ public void ReadFiniteDouble_PreservesUnderlyingPrecisionWhileDisplayIsRounded()
+ {
+ const double productionValue = 0.04000000000000001;
+ NumericUpDown field = AuthoringNumericControls.Create(
+ "curvature",
+ AuthoringNumericParameterKind.CurvaturePerMeter,
+ productionValue);
+
+ Assert.Equal("0.04", field.Value!.Value.ToString(
+ field.FormatString,
+ field.NumberFormat));
+ Assert.Equal(
+ productionValue,
+ AuthoringNumericControls.ReadFiniteDouble(field, "curvature"));
+ }
+
+ [Fact]
+ public void ReadFiniteDouble_AcceptsTypedInvariantDecimalAndExponentValues()
+ {
+ NumericUpDown field = AuthoringNumericControls.Create(
+ "curvature",
+ AuthoringNumericParameterKind.CurvaturePerMeter,
+ 0.0);
+
+ field.Text = "0.01234567";
+ Assert.Equal(
+ 0.01234567,
+ AuthoringNumericControls.ReadFiniteDouble(field, "curvature"),
+ 12);
+
+ field.Text = "1e-3";
+ Assert.Equal(
+ 0.001,
+ AuthoringNumericControls.ReadFiniteDouble(field, "curvature"),
+ 12);
+ }
+
+ [Fact]
+ public void ReadFiniteDouble_RejectsInvalidAndOutOfRangeTypedText()
+ {
+ NumericUpDown field = AuthoringNumericControls.Create(
+ "curvature",
+ AuthoringNumericParameterKind.CurvaturePerMeter,
+ 0.0);
+
+ field.Text = "not-a-number";
+ Assert.Throws(() =>
+ AuthoringNumericControls.ReadFiniteDouble(field, "curvature"));
+
+ field.Text = "101";
+ Assert.Throws(() =>
+ AuthoringNumericControls.ReadFiniteDouble(field, "curvature"));
+ }
+
+ [Fact]
+ public void IncrementedValue_UsesConfiguredArrowStepWithoutRoundingDrift()
+ {
+ NumericUpDown field = AuthoringNumericControls.Create(
+ "curvature",
+ AuthoringNumericParameterKind.CurvaturePerMeter,
+ 0.04);
+
+ field.Value += field.Increment;
+ field.Text = field.Value!.Value.ToString(field.FormatString, field.NumberFormat);
+
+ Assert.Equal(0.041m, field.Value);
+ Assert.Equal(
+ 0.041,
+ AuthoringNumericControls.ReadFiniteDouble(field, "curvature"),
+ 12);
+ }
+
+ [Fact]
+ public void TransitionalEditors_DeclareNumericControlsOnlyForEditableParameters()
+ {
+ const BindingFlags privateInstance = BindingFlags.Instance | BindingFlags.NonPublic;
+ string[] dialogNumericFields =
+ {
+ "lengthField",
+ "rollField",
+ "radiusField",
+ "startCurvatureField",
+ "endCurvatureField"
+ };
+
+ Assert.All(
+ dialogNumericFields,
+ fieldName => Assert.Equal(
+ typeof(NumericUpDown),
+ typeof(SectionEditorDialog).GetField(fieldName, privateInstance)?.FieldType));
+ Assert.Equal(
+ typeof(TextBox),
+ typeof(SectionEditorDialog).GetField("idField", privateInstance)?.FieldType);
+
+ FieldInfo? inspectorNumericFields = typeof(InspectorPaneControl).GetField(
+ "numericInspectorFields",
+ privateInstance);
+ FieldInfo? inspectorTextFields = typeof(InspectorPaneControl).GetField(
+ "inspectorFields",
+ privateInstance);
+ Assert.Equal(
+ typeof(Dictionary),
+ inspectorNumericFields?.FieldType);
+ Assert.Equal(
+ typeof(Dictionary),
+ inspectorTextFields?.FieldType);
+ }
+}
diff --git a/Quantum.Tests/Editor/EditorTestDocumentFactory.cs b/Quantum.Tests/Editor/EditorTestDocumentFactory.cs
new file mode 100644
index 0000000..6a61d9b
--- /dev/null
+++ b/Quantum.Tests/Editor/EditorTestDocumentFactory.cs
@@ -0,0 +1,25 @@
+using Quantum.Editor.Avalonia.Models;
+using Quantum.Editor.Avalonia.Services;
+using Quantum.Editor.Avalonia.Services.Documents;
+
+namespace Quantum.Tests;
+
+internal static class EditorTestDocumentFactory
+{
+ public static TrackEditorDocument ActivateShowcase(
+ EditorWorkspace workspace,
+ bool markDirty = false)
+ {
+ var document = TrackEditorDocument.Create(
+ TrackPackageFactory.CreateShowcasePackage(),
+ "Showcase Layout");
+ if (markDirty)
+ {
+ document.MarkDirty();
+ }
+
+ workspace.Documents.SetActiveDocument(document);
+ workspace.Select(EditorSelection.Track);
+ return document;
+ }
+}
diff --git a/Quantum.Tests/Editor/EditorWorkspaceTests.cs b/Quantum.Tests/Editor/EditorWorkspaceTests.cs
index 091ff19..1303598 100644
--- a/Quantum.Tests/Editor/EditorWorkspaceTests.cs
+++ b/Quantum.Tests/Editor/EditorWorkspaceTests.cs
@@ -11,7 +11,7 @@ namespace Quantum.Tests;
public sealed class EditorWorkspaceTests
{
[Fact]
- public void NewDocument_CreatesAuthoritativeConnectedGraphAndVisibleCompilation()
+ public void NewDocument_CreatesEmptyAuthoringGraphWithoutCompiledOrPersistablePreview()
{
var workspace = new EditorWorkspace();
@@ -19,45 +19,141 @@ public void NewDocument_CreatesAuthoritativeConnectedGraphAndVisibleCompilation(
Assert.True(document.IsDirty);
Assert.NotNull(document.Graph);
- Assert.NotNull(document.GraphCompileResult);
+ Assert.True(document.IsEmpty);
+ Assert.False(document.CanSave);
+ Assert.Null(document.GraphCompileResult);
+ Assert.Null(document.Compilation);
+ Assert.Null(document.Package);
+ Assert.Empty(document.Graph!.Nodes);
+ Assert.Empty(document.Graph.Edges);
+ Assert.Empty(workspace.GraphNodes);
+ Assert.Empty(workspace.ViewportSnapshot.Samples);
+ Assert.Null(workspace.EngineeringSnapshot);
+ Assert.Equal(EditorSelection.Track, workspace.CurrentSelection);
+ Assert.Empty(workspace.OutlinerNodes);
+ }
+
+ [Fact]
+ public void AddFirstSection_CompilesSelectsAndSupportsUndoRedoBackToEmpty()
+ {
+ var workspace = new EditorWorkspace();
+ TrackEditorDocument document = workspace.NewDocument();
+
+ bool added = workspace.AddSection(
+ new StraightSectionDefinition("straight-1", 10.0));
+
+ Assert.True(added);
+ Assert.False(document.IsEmpty);
+ Assert.True(document.CanSave);
Assert.NotNull(document.Compilation);
+ Assert.Equal(10.0, document.Compilation!.TotalLength, 9);
+ Assert.Equal("straight-1", Assert.Single(workspace.GraphNodes).NodeId);
+ Assert.Equal("straight-1", workspace.CurrentSelection!.NodeId);
+ Assert.True(workspace.UndoRedo.CanUndo);
+
+ Assert.True(workspace.UndoLast());
+ Assert.True(document.IsEmpty);
+ Assert.False(document.CanSave);
+ Assert.Null(document.Compilation);
+ Assert.Empty(workspace.GraphNodes);
+ Assert.Empty(workspace.ViewportSnapshot.Samples);
+
+ Assert.True(workspace.RedoLast());
+ Assert.True(document.CanSave);
+ Assert.Equal("straight-1", Assert.Single(workspace.GraphNodes).NodeId);
+ Assert.Equal(10.0, document.Compilation!.TotalLength, 9);
+ }
+
+ [Fact]
+ public void StructuralSectionWorkflow_InsertsMovesDeletesAndKeepsSelectionStable()
+ {
+ var workspace = new EditorWorkspace();
+ TrackEditorDocument document = workspace.NewDocument();
+ Assert.True(workspace.AddSection(
+ new StraightSectionDefinition("a", 5.0)));
+ Assert.True(workspace.InsertSectionAfter(
+ "a",
+ new ConstantCurvatureSectionDefinition("c", 5.0, 20.0)));
+ Assert.True(workspace.InsertSectionBefore(
+ "c",
+ new CurvatureTransitionSectionDefinition("b", 5.0, 0.0, 0.05)));
+
Assert.Equal(
- new[]
- {
- "launch",
- "curve-in",
- "sweeper",
- "reverse-transition",
- "return-curve",
- "curve-out",
- "brake-run"
- },
+ new[] { "a", "b", "c" },
+ workspace.GraphNodes.Select(node => node.NodeId));
+ Assert.Equal("b", workspace.CurrentSelection!.NodeId);
+ Assert.Equal(15.0, document.Compilation!.TotalLength, 9);
+
+ Assert.True(workspace.MoveSectionDown("a"));
+ Assert.Equal(
+ new[] { "b", "a", "c" },
+ workspace.GraphNodes.Select(node => node.NodeId));
+ Assert.Equal("a", workspace.CurrentSelection!.NodeId);
+
+ Assert.True(workspace.DeleteSection("a"));
+ Assert.Equal(
+ new[] { "b", "c" },
+ workspace.GraphNodes.Select(node => node.NodeId));
+ Assert.Equal("c", workspace.CurrentSelection!.NodeId);
+ Assert.Equal(10.0, document.Compilation!.TotalLength, 9);
+
+ Assert.True(workspace.UndoLast());
+ Assert.Equal(
+ new[] { "b", "a", "c" },
workspace.GraphNodes.Select(node => node.NodeId));
- Assert.Equal(7, document.Graph!.Nodes.Count);
- Assert.Equal(6, document.Graph.Edges.Count);
- Assert.Equal(195.0, document.Compilation!.TotalLength, 9);
- Assert.True(workspace.ViewportSnapshot.Samples.Count > 100);
- Assert.Equal(document.Compilation.TotalLength, workspace.ViewportSnapshot.TotalLength, 9);
- EngineeringSnapshot snapshot = Assert.IsType(workspace.EngineeringSnapshot);
- Assert.Equal(snapshot.SampleCount, workspace.ViewportSnapshot.Samples.Count);
- Assert.Equal(snapshot.Geometry[20].Position, workspace.ViewportSnapshot.Samples[20].Position);
- Assert.Equal(snapshot.Geometry[20].Tangent, workspace.ViewportSnapshot.Samples[20].Tangent);
- Assert.Equal(snapshot.BankingRollRadians[20] * 180.0 / System.Math.PI,
- workspace.ViewportSnapshot.Samples[20].RollDegrees,
- 9);
- Assert.NotEmpty(workspace.ViewportSnapshot.Diagnostics);
- Assert.Contains(
- workspace.ViewportSnapshot.Diagnostics,
- diagnostic => diagnostic.StartsWith("Math Plot snapshot ", StringComparison.Ordinal));
- Assert.Equal(EditorSelection.Track, workspace.CurrentSelection);
- Assert.Single(workspace.OutlinerNodes);
}
[Fact]
- public void ApplyGraphEdit_CompilesRefreshesViewportAndSupportsAtomicUndoRedo()
+ public void AddSection_InvalidOrDuplicateDefinitionPreservesCompiledTrackAndHistory()
{
var workspace = new EditorWorkspace();
TrackEditorDocument document = workspace.NewDocument();
+ Assert.True(workspace.AddSection(
+ new StraightSectionDefinition("section", 5.0)));
+ workspace.UndoRedo.Clear();
+ TrackAuthoringGraph beforeGraph = document.Graph!;
+ TrackAuthoringCompilation beforeCompilation = document.Compilation!;
+ TrackViewportSnapshot beforeViewport = workspace.ViewportSnapshot;
+
+ bool duplicate = workspace.AddSection(
+ new StraightSectionDefinition("section", 2.0));
+
+ Assert.False(duplicate);
+ Assert.Same(beforeGraph, document.Graph);
+ Assert.Same(beforeCompilation, document.Compilation);
+ Assert.Same(beforeViewport, workspace.ViewportSnapshot);
+ Assert.False(workspace.UndoRedo.CanUndo);
+ Assert.StartsWith("Edit rejected:", workspace.StatusMessage);
+ }
+
+ [Fact]
+ public void LengthChangingStructuralEdit_WithExplicitBankingIsRejectedWithoutRemapping()
+ {
+ var workspace = new EditorWorkspace();
+ TrackEditorDocument document =
+ EditorTestDocumentFactory.ActivateShowcase(workspace);
+ document.MarkClean();
+ TrackAuthoringGraph beforeGraph = document.Graph!;
+ TrackAuthoringCompilation beforeCompilation = document.Compilation!;
+
+ bool inserted = workspace.InsertSectionAfter(
+ "launch",
+ new StraightSectionDefinition("inserted", 5.0));
+
+ Assert.False(inserted);
+ Assert.Same(beforeGraph, document.Graph);
+ Assert.Same(beforeCompilation, document.Compilation);
+ Assert.False(document.IsDirty);
+ Assert.False(workspace.UndoRedo.CanUndo);
+ Assert.Contains("AuthoringCompilationFailed", workspace.StatusMessage);
+ Assert.Equal(5, document.Graph!.Banking!.Keys.Count);
+ }
+
+ [Fact]
+ public void ApplyGraphEdit_CompilesRefreshesViewportAndSupportsAtomicUndoRedo()
+ {
+ var workspace = new EditorWorkspace();
+ TrackEditorDocument document = EditorTestDocumentFactory.ActivateShowcase(workspace);
document.MarkClean();
TrackAuthoringGraph beforeGraph = document.Graph!;
var beforeCompilation = document.Compilation;
@@ -99,7 +195,7 @@ public void ApplyGraphEdit_CompilesRefreshesViewportAndSupportsAtomicUndoRedo()
public void ApplyGraphEdit_InvalidTopologyCannotChangeDocumentDirtyStateViewportOrHistory()
{
var workspace = new EditorWorkspace();
- TrackEditorDocument document = workspace.NewDocument();
+ TrackEditorDocument document = EditorTestDocumentFactory.ActivateShowcase(workspace);
document.MarkClean();
TrackAuthoringGraph beforeGraph = document.Graph!;
var beforeCompilation = document.Compilation;
@@ -131,7 +227,7 @@ public void ApplyGraphEdit_InvalidTopologyCannotChangeDocumentDirtyStateViewport
public void ApplyGraphEdit_InvalidSectionConstructionCannotEnterHistory()
{
var workspace = new EditorWorkspace();
- TrackEditorDocument document = workspace.NewDocument();
+ TrackEditorDocument document = EditorTestDocumentFactory.ActivateShowcase(workspace);
document.MarkClean();
TrackAuthoringGraph beforeGraph = document.Graph!;
@@ -151,7 +247,7 @@ public void ApplyGraphEdit_InvalidSectionConstructionCannotEnterHistory()
public void ApplyPackageEdit_CompatibilityBridgeCommitsOnlyGraphSnapshots()
{
var workspace = new EditorWorkspace();
- TrackEditorDocument document = workspace.NewDocument();
+ TrackEditorDocument document = EditorTestDocumentFactory.ActivateShowcase(workspace);
document.MarkClean();
TrackAuthoringGraph beforeGraph = document.Graph!;
@@ -172,7 +268,7 @@ public void ApplyPackageEdit_CompatibilityBridgeCommitsOnlyGraphSnapshots()
public void SetStationCursor_UsesCanonicalEngineeringStationAndNotifiesOnce()
{
var workspace = new EditorWorkspace();
- workspace.NewDocument();
+ EditorTestDocumentFactory.ActivateShowcase(workspace);
EngineeringSnapshot snapshot = workspace.EngineeringSnapshot!;
int notifications = 0;
workspace.StationCursorChanged += (_, _) => notifications++;
@@ -191,7 +287,7 @@ public void SetStationCursor_UsesCanonicalEngineeringStationAndNotifiesOnce()
public void SelectStationAt_UsesNearestCanonicalSampleAndSynchronizesEditorSelection()
{
var workspace = new EditorWorkspace();
- TrackEditorDocument document = workspace.NewDocument();
+ TrackEditorDocument document = EditorTestDocumentFactory.ActivateShowcase(workspace);
EngineeringSnapshot snapshot = workspace.EngineeringSnapshot!;
TrackViewportSnapshot viewport = workspace.ViewportSnapshot;
TrackAuthoringGraph graph = document.Graph!;
@@ -223,7 +319,7 @@ public void SelectStationAt_UsesNearestCanonicalSampleAndSynchronizesEditorSelec
public void SectionHighlight_HoverOverridesSelectionAndRestoresItAcrossAllViews()
{
var workspace = new EditorWorkspace();
- workspace.NewDocument();
+ EditorTestDocumentFactory.ActivateShowcase(workspace);
EditorGraphNode selected = workspace.GraphNodes.Single(node => node.NodeId == "sweeper");
EditorSelection selectedSection = selected.Selection;
EngineeringSnapshot snapshot = workspace.EngineeringSnapshot!;
@@ -250,7 +346,7 @@ public void SectionHighlight_HoverOverridesSelectionAndRestoresItAcrossAllViews(
public void SelectedMathPlotStation_ExposesCanonicalInspectorValuesFromSharedSnapshot()
{
var workspace = new EditorWorkspace();
- TrackEditorDocument document = workspace.NewDocument();
+ TrackEditorDocument document = EditorTestDocumentFactory.ActivateShowcase(workspace);
const int sampleIndex = 64;
workspace.SelectStationSample(sampleIndex);
@@ -272,14 +368,14 @@ public void SelectedMathPlotStation_ExposesCanonicalInspectorValuesFromSharedSna
Assert.True(double.IsFinite(
EngineeringPlotProjection.GetValue(snapshot, EngineeringPlotKind.Yaw, sampleIndex)!.Value));
Assert.Equal(workspace.CurrentSelection.NodeId, section.Id);
- Assert.True(section.Section.Length > 0.0);
+ Assert.True(Assert.IsAssignableFrom(section.Section).Length > 0.0);
}
[Fact]
public void ApplyPackageEdit_AncillaryMutationIsRejectedWithoutHistory()
{
var workspace = new EditorWorkspace();
- TrackEditorDocument document = workspace.NewDocument();
+ TrackEditorDocument document = EditorTestDocumentFactory.ActivateShowcase(workspace);
document.MarkClean();
TrackAuthoringGraph beforeGraph = document.Graph!;
@@ -299,7 +395,7 @@ public void ApplyPackageEdit_AncillaryMutationIsRejectedWithoutHistory()
public void GraphNodeSelectionUsesStableNodeIdAndRouteIndex()
{
var workspace = new EditorWorkspace();
- workspace.NewDocument();
+ EditorTestDocumentFactory.ActivateShowcase(workspace);
EditorGraphNode sweeper = workspace.GraphNodes.Single(node => node.NodeId == "sweeper");
workspace.Select(sweeper.Selection);
diff --git a/Quantum.Tests/Editor/EngineeringPlotProjectionTests.cs b/Quantum.Tests/Editor/EngineeringPlotProjectionTests.cs
index 2a83e04..242d688 100644
--- a/Quantum.Tests/Editor/EngineeringPlotProjectionTests.cs
+++ b/Quantum.Tests/Editor/EngineeringPlotProjectionTests.cs
@@ -10,7 +10,7 @@ public sealed class EngineeringPlotProjectionTests
public void GetValue_ProjectsEveryPlotDirectlyFromEngineeringSnapshot()
{
var workspace = new EditorWorkspace();
- workspace.NewDocument();
+ EditorTestDocumentFactory.ActivateShowcase(workspace);
EngineeringSnapshot snapshot = workspace.EngineeringSnapshot!;
const int sampleIndex = 37;
EngineeringGeometrySample geometry = snapshot.Geometry[sampleIndex];
@@ -63,7 +63,7 @@ public void GetValue_ProjectsEveryPlotDirectlyFromEngineeringSnapshot()
public void FindNearestSampleIndex_ClampsAndUsesCanonicalStationGrid()
{
var workspace = new EditorWorkspace();
- workspace.NewDocument();
+ EditorTestDocumentFactory.ActivateShowcase(workspace);
EngineeringSnapshot snapshot = workspace.EngineeringSnapshot!;
double lowerStation = snapshot.StationGrid[10];
double upperStation = snapshot.StationGrid[11];
@@ -90,7 +90,7 @@ public void FindNearestSampleIndex_ClampsAndUsesCanonicalStationGrid()
public void SnapshotNavigation_UsesTheSameResolvedSectionForMathPlotsAndViewport()
{
var workspace = new EditorWorkspace();
- workspace.NewDocument();
+ EditorTestDocumentFactory.ActivateShowcase(workspace);
EngineeringSnapshot snapshot = workspace.EngineeringSnapshot!;
for (int sampleIndex = 0; sampleIndex < snapshot.SampleCount; sampleIndex++)
diff --git a/Quantum.Tests/Editor/PaneExtractionContractTests.cs b/Quantum.Tests/Editor/PaneExtractionContractTests.cs
index 6bd95d1..8b5b026 100644
--- a/Quantum.Tests/Editor/PaneExtractionContractTests.cs
+++ b/Quantum.Tests/Editor/PaneExtractionContractTests.cs
@@ -37,7 +37,14 @@ public void RoutePane_ExposesDocumentRouteSelectionAndInteractionContract()
AssertProperty(nameof(RoutePaneControl.Selection), canWrite: true);
AssertProperty(nameof(RoutePaneControl.HighlightedSectionIndex), canWrite: true);
Assert.NotNull(typeof(RoutePaneControl).GetEvent(nameof(RoutePaneControl.NodeSelected)));
+ Assert.NotNull(typeof(RoutePaneControl).GetEvent(nameof(RoutePaneControl.AddSectionRequested)));
+ Assert.NotNull(typeof(RoutePaneControl).GetEvent(nameof(RoutePaneControl.InsertBeforeRequested)));
+ Assert.NotNull(typeof(RoutePaneControl).GetEvent(nameof(RoutePaneControl.InsertAfterRequested)));
+ Assert.NotNull(typeof(RoutePaneControl).GetEvent(nameof(RoutePaneControl.DeleteSectionRequested)));
+ Assert.NotNull(typeof(RoutePaneControl).GetEvent(nameof(RoutePaneControl.MoveUpRequested)));
+ Assert.NotNull(typeof(RoutePaneControl).GetEvent(nameof(RoutePaneControl.MoveDownRequested)));
Assert.NotNull(typeof(RoutePaneControl).GetEvent(nameof(RoutePaneControl.SectionPointerChanged)));
+ Assert.True(typeof(Window).IsAssignableFrom(typeof(SectionEditorDialog)));
}
[Fact]
diff --git a/Quantum.Tests/Editor/TrackDocumentFileServiceTests.cs b/Quantum.Tests/Editor/TrackDocumentFileServiceTests.cs
index 4af8f10..14793ba 100644
--- a/Quantum.Tests/Editor/TrackDocumentFileServiceTests.cs
+++ b/Quantum.Tests/Editor/TrackDocumentFileServiceTests.cs
@@ -6,6 +6,64 @@ namespace Quantum.Tests;
public sealed class TrackDocumentFileServiceTests
{
+ [Fact]
+ public void EmptyToAuthoredWorkflow_SaveOpenPreservesCreatedTypesOrderAndParameters()
+ {
+ string tempDirectory = CreateTempDirectory();
+ string path = Path.Combine(tempDirectory, "m166-authored.qcwtrack.json");
+ var workspace = new EditorWorkspace();
+
+ try
+ {
+ TrackEditorDocument document = workspace.NewDocument();
+ Assert.False(document.CanSave);
+ Assert.True(workspace.AddSection(
+ new StraightSectionDefinition("entry", 8.0, 0.1)));
+ Assert.True(workspace.InsertSectionAfter(
+ "entry",
+ new CurvatureTransitionSectionDefinition(
+ "transition",
+ 6.0,
+ 0.0,
+ 0.04,
+ rollRadians: 0.2)));
+ Assert.True(workspace.InsertSectionAfter(
+ "transition",
+ new ConstantCurvatureSectionDefinition(
+ "curve",
+ 10.0,
+ -25.0,
+ 0.3)));
+
+ workspace.SaveDocument(path);
+ TrackEditorDocument reopened = workspace.OpenDocument(path);
+
+ Assert.False(reopened.IsDirty);
+ Assert.True(reopened.CanSave);
+ Assert.Equal(
+ new[] { "entry", "transition", "curve" },
+ workspace.GraphNodes.Select(node => node.NodeId));
+ Assert.IsType(
+ reopened.GraphCompileResult!.OrderedNodes[0].Section);
+ CurvatureTransitionSectionDefinition transition =
+ Assert.IsType(
+ reopened.GraphCompileResult.OrderedNodes[1].Section);
+ ConstantCurvatureSectionDefinition curve =
+ Assert.IsType(
+ reopened.GraphCompileResult.OrderedNodes[2].Section);
+ Assert.Equal(0.04, transition.EndCurvature, 12);
+ Assert.Equal(-25.0, curve.Radius, 12);
+ Assert.Equal(24.0, reopened.Compilation!.TotalLength, 12);
+ }
+ finally
+ {
+ if (Directory.Exists(tempDirectory))
+ {
+ Directory.Delete(tempDirectory, recursive: true);
+ }
+ }
+ }
+
[Fact]
public void SaveAndOpen_PreservesEditedGraphPackageCompilationAndCleanState()
{
@@ -54,7 +112,8 @@ public void SavepointDirtyStateTracksUndoAndRedoByPersistedGraphContent()
try
{
- TrackEditorDocument document = workspace.NewDocument();
+ TrackEditorDocument document =
+ EditorTestDocumentFactory.ActivateShowcase(workspace, markDirty: true);
workspace.SaveDocument(path);
Assert.False(document.IsDirty);
diff --git a/Quantum.Tests/Track/CoasterApiBoundaryContractTests.cs b/Quantum.Tests/Track/CoasterApiBoundaryContractTests.cs
index a4b6542..b025b11 100644
--- a/Quantum.Tests/Track/CoasterApiBoundaryContractTests.cs
+++ b/Quantum.Tests/Track/CoasterApiBoundaryContractTests.cs
@@ -293,6 +293,11 @@ public void TrackAuthoringApis_ExposeNoSplineUiOrEngineTypes()
{
typeof(TrackStartPose),
typeof(TrackBankingDefinition),
+ typeof(TrackAuthoringSectionFamily),
+ typeof(TrackAuthoringSectionTypeIds),
+ typeof(TrackAuthoringSectionDefinition),
+ typeof(TrackAuthoringSectionTypeDescriptor),
+ typeof(TrackAuthoringSectionCatalog),
typeof(TrackAuthoringDefinition),
typeof(GeometricSectionDefinition),
typeof(StraightSectionDefinition),
@@ -307,6 +312,9 @@ public void TrackAuthoringApis_ExposeNoSplineUiOrEngineTypes()
typeof(TrackAuthoringGraphEdge),
typeof(TrackAuthoringGraphDiagnosticCode),
typeof(TrackAuthoringGraphDiagnostic),
+ typeof(TrackAuthoringGraphRouteResult),
+ typeof(TrackAuthoringGraphRouteValidator),
+ typeof(TrackAuthoringGraphOperations),
typeof(TrackAuthoringGraphCompileResult),
typeof(TrackAuthoringGraphCompiler),
typeof(TrackAuthoringBoundaryContinuityDiagnostics),
diff --git a/Quantum.Tests/Track/TrackAuthoringCandidateEvaluatorTests.cs b/Quantum.Tests/Track/TrackAuthoringCandidateEvaluatorTests.cs
new file mode 100644
index 0000000..334674d
--- /dev/null
+++ b/Quantum.Tests/Track/TrackAuthoringCandidateEvaluatorTests.cs
@@ -0,0 +1,306 @@
+using Quantum.Track;
+using Quantum.Track.Authoring;
+
+namespace Quantum.Tests;
+
+public sealed class TrackAuthoringCandidateEvaluatorTests
+{
+ private const double Tolerance = 1e-9;
+
+ [Fact]
+ public void AppendCandidate_UsesProductionDefinitionAndRetainsExactResults()
+ {
+ TrackAuthoringGraph source = Graph();
+ var section = new StraightSectionDefinition("entry", 10.0, 0.1);
+ TrackAuthoringCandidateOperation operation =
+ TrackAuthoringCandidateOperation.Append(section);
+
+ TrackAuthoringCandidateEvaluation result =
+ TrackAuthoringCandidateEvaluator.Evaluate(source, operation);
+
+ Assert.True(result.CommitEligible);
+ Assert.Same(source, result.SourceGraph);
+ Assert.Same(operation, result.Operation);
+ Assert.NotNull(result.CandidateGraph);
+ Assert.Same(section, Assert.Single(result.CandidateGraph!.Nodes).Section);
+ Assert.NotNull(result.RouteResult);
+ Assert.NotNull(result.CompileResult);
+ Assert.Same(result.CompileResult!.Compilation, result.Compilation);
+ Assert.Empty(result.Diagnostics);
+ }
+
+ [Fact]
+ public void InsertBeforeAndAfterCandidates_ApplyExistingProductionOperations()
+ {
+ TrackAuthoringGraph source = Graph(
+ new StraightSectionDefinition("a", 2.0),
+ new StraightSectionDefinition("c", 2.0));
+
+ TrackAuthoringCandidateEvaluation before = TrackAuthoringCandidateEvaluator.Evaluate(
+ source,
+ TrackAuthoringCandidateOperation.InsertBefore(
+ "c",
+ new ConstantCurvatureSectionDefinition("b", 3.0, 20.0)));
+ TrackAuthoringCandidateEvaluation after = TrackAuthoringCandidateEvaluator.Evaluate(
+ source,
+ TrackAuthoringCandidateOperation.InsertAfter(
+ "a",
+ new CurvatureTransitionSectionDefinition("b", 3.0, 0.0, 0.05)));
+
+ Assert.True(before.CommitEligible);
+ Assert.True(after.CommitEligible);
+ Assert.Equal(new[] { "a", "b", "c" }, OrderedIds(before.CandidateGraph!));
+ Assert.Equal(new[] { "a", "b", "c" }, OrderedIds(after.CandidateGraph!));
+ Assert.Equal(
+ TrackAuthoringSectionTypeIds.ConstantCurvature,
+ before.CandidateGraph!.Nodes.Single(node => node.Id == "b").TypeId);
+ Assert.Equal(
+ TrackAuthoringSectionTypeIds.CurvatureTransition,
+ after.CandidateGraph!.Nodes.Single(node => node.Id == "b").TypeId);
+ }
+
+ [Fact]
+ public void ReplaceCandidate_PreservesStableIdAndLeavesSourceUnchanged()
+ {
+ var original = new StraightSectionDefinition("section", 4.0);
+ TrackAuthoringGraph source = Graph(original);
+ var replacement = new ConstantCurvatureSectionDefinition("section", 8.0, -30.0);
+
+ TrackAuthoringCandidateEvaluation result = TrackAuthoringCandidateEvaluator.Evaluate(
+ source,
+ TrackAuthoringCandidateOperation.Replace("section", replacement));
+
+ Assert.True(result.CommitEligible);
+ Assert.Same(original, Assert.Single(source.Nodes).Section);
+ Assert.Same(replacement, Assert.Single(result.CandidateGraph!.Nodes).Section);
+ Assert.NotSame(source, result.CandidateGraph);
+ }
+
+ [Fact]
+ public void RoutineOperationAndCompilationFailures_AreStructuredAndNotCommitEligible()
+ {
+ TrackAuthoringGraph source = Graph(new StraightSectionDefinition("existing", 10.0));
+ TrackAuthoringCandidateEvaluation duplicate = TrackAuthoringCandidateEvaluator.Evaluate(
+ source,
+ TrackAuthoringCandidateOperation.Append(
+ new StraightSectionDefinition("existing", 2.0)));
+
+ var banking = new TrackBankingDefinition(new[]
+ {
+ new BankingProfileKey(0.0, 0.0),
+ new BankingProfileKey(10.0, 0.0)
+ });
+ TrackAuthoringGraph bankedSource = new TrackAuthoringGraph(
+ source.Nodes,
+ source.Edges,
+ source.StartPose,
+ banking);
+ TrackAuthoringCandidateEvaluation compilationFailure =
+ TrackAuthoringCandidateEvaluator.Evaluate(
+ bankedSource,
+ TrackAuthoringCandidateOperation.Append(
+ new StraightSectionDefinition("extra", 5.0)));
+
+ Assert.False(duplicate.CommitEligible);
+ Assert.Null(duplicate.CandidateGraph);
+ Assert.Null(duplicate.CompileResult);
+ Assert.Equal(
+ TrackAuthoringGraphDiagnosticCode.CandidateOperationFailed,
+ Assert.Single(duplicate.Diagnostics).Code);
+
+ Assert.False(compilationFailure.CommitEligible);
+ Assert.NotNull(compilationFailure.CandidateGraph);
+ Assert.NotNull(compilationFailure.CompileResult);
+ Assert.Null(compilationFailure.Compilation);
+ Assert.Contains(
+ compilationFailure.Diagnostics,
+ diagnostic => diagnostic.Code ==
+ TrackAuthoringGraphDiagnosticCode.AuthoringCompilationFailed);
+ }
+
+ [Fact]
+ public void StaleDetection_UsesConservativeImmutableGraphIdentity()
+ {
+ TrackAuthoringGraph source = Graph(new StraightSectionDefinition("a", 3.0));
+ TrackAuthoringCandidateEvaluation result = TrackAuthoringCandidateEvaluator.Evaluate(
+ source,
+ TrackAuthoringCandidateOperation.Append(
+ new StraightSectionDefinition("b", 3.0)));
+ TrackAuthoringGraph equivalentSnapshot = Graph(
+ new StraightSectionDefinition("a", 3.0));
+
+ Assert.False(result.IsStaleComparedTo(source));
+ Assert.True(result.IsStaleComparedTo(equivalentSnapshot));
+ Assert.True(result.IsStaleComparedTo(result.CandidateGraph!));
+ }
+
+ [Fact]
+ public void SuccessfulEvaluation_InvokesCompilerExactlyOnce()
+ {
+ TrackAuthoringGraph source = Graph(new StraightSectionDefinition("a", 3.0));
+ int compileCount = 0;
+
+ TrackAuthoringCandidateEvaluation result = TrackAuthoringCandidateEvaluator.Evaluate(
+ source,
+ TrackAuthoringCandidateOperation.Append(
+ new StraightSectionDefinition("b", 4.0)),
+ graph =>
+ {
+ compileCount++;
+ return TrackAuthoringGraphCompiler.Compile(graph);
+ });
+
+ Assert.True(result.CommitEligible);
+ Assert.Equal(1, compileCount);
+ }
+
+ [Fact]
+ public void InvalidCandidateRoute_RetainsValidationResultAndDoesNotCompile()
+ {
+ var source = new TrackAuthoringGraph(
+ new[]
+ {
+ new TrackAuthoringGraphNode(new StraightSectionDefinition("a", 3.0)),
+ new TrackAuthoringGraphNode(new StraightSectionDefinition("b", 3.0))
+ },
+ Array.Empty());
+ int compileCount = 0;
+
+ TrackAuthoringCandidateEvaluation result = TrackAuthoringCandidateEvaluator.Evaluate(
+ source,
+ TrackAuthoringCandidateOperation.Replace(
+ "a",
+ new ConstantCurvatureSectionDefinition("a", 3.0, 25.0)),
+ graph =>
+ {
+ compileCount++;
+ return TrackAuthoringGraphCompiler.Compile(graph);
+ });
+
+ Assert.False(result.CommitEligible);
+ Assert.NotNull(result.CandidateGraph);
+ Assert.NotNull(result.RouteResult);
+ Assert.False(result.RouteResult!.Success);
+ Assert.Null(result.CompileResult);
+ Assert.Equal(0, compileCount);
+ Assert.Contains(
+ result.Diagnostics,
+ diagnostic => diagnostic.Code == TrackAuthoringGraphDiagnosticCode.DisconnectedNode);
+ }
+
+ [Fact]
+ public void ExtensibleCompoundOperation_UsesTheSameProductionCandidatePipeline()
+ {
+ TrackAuthoringGraph source = Graph(
+ new StraightSectionDefinition("a", 3.0),
+ new StraightSectionDefinition("b", 4.0));
+ var operation = new TestCompoundOperation(graph =>
+ {
+ TrackAuthoringGraph first = TrackAuthoringGraphOperations.Replace(
+ graph,
+ "a",
+ new ConstantCurvatureSectionDefinition("a", 5.0, 25.0));
+ return TrackAuthoringGraphOperations.Replace(
+ first,
+ "b",
+ new CurvatureTransitionSectionDefinition("b", 6.0, 0.04, 0.0));
+ });
+ int compileCount = 0;
+
+ TrackAuthoringCandidateEvaluation result = TrackAuthoringCandidateEvaluator.Evaluate(
+ source,
+ operation,
+ graph =>
+ {
+ compileCount++;
+ return TrackAuthoringGraphCompiler.Compile(graph);
+ });
+
+ Assert.True(result.CommitEligible);
+ Assert.Same(operation, result.Operation);
+ Assert.Equal("test.compound", result.Operation.OperationTypeId);
+ Assert.Equal(1, compileCount);
+ Assert.IsType(source.Nodes[0].Section);
+ Assert.IsType(source.Nodes[1].Section);
+ Assert.IsType(result.CandidateGraph!.Nodes[0].Section);
+ Assert.IsType(result.CandidateGraph.Nodes[1].Section);
+ }
+
+ [Fact]
+ public void CandidateEvaluation_MatchesDirectProductionOperationAndCompiler()
+ {
+ TrackAuthoringGraph source = Graph(
+ new StraightSectionDefinition("entry", 5.0),
+ new ConstantCurvatureSectionDefinition("turn", 8.0, 20.0));
+ var inserted = new CurvatureTransitionSectionDefinition(
+ "transition",
+ 6.0,
+ 0.0,
+ 0.05);
+
+ TrackAuthoringCandidateEvaluation evaluated = TrackAuthoringCandidateEvaluator.Evaluate(
+ source,
+ TrackAuthoringCandidateOperation.InsertBefore("turn", inserted));
+ TrackAuthoringGraph directGraph = TrackAuthoringGraphOperations.InsertBefore(
+ source,
+ "turn",
+ inserted);
+ TrackAuthoringGraphCompileResult direct = TrackAuthoringGraphCompiler.Compile(directGraph);
+
+ Assert.True(evaluated.CommitEligible);
+ Assert.True(direct.Success);
+ Assert.Equal(OrderedIds(directGraph), OrderedIds(evaluated.CandidateGraph!));
+ Assert.Equal(directGraph.Edges.Count, evaluated.CandidateGraph!.Edges.Count);
+ Assert.Equal(direct.Compilation!.TotalLength, evaluated.Compilation!.TotalLength);
+
+ TrackFrame evaluatedEnd = new TrackEvaluator(evaluated.Compilation.Runtime)
+ .EvaluateFrameAtDistance(evaluated.Compilation.TotalLength);
+ TrackFrame directEnd = new TrackEvaluator(direct.Compilation.Runtime)
+ .EvaluateFrameAtDistance(direct.Compilation.TotalLength);
+ AssertVectorNear(directEnd.Position, evaluatedEnd.Position);
+ AssertVectorNear(directEnd.Tangent, evaluatedEnd.Tangent);
+ }
+
+ private static TrackAuthoringGraph Graph(params GeometricSectionDefinition[] sections)
+ {
+ var nodes = sections.Select(section => new TrackAuthoringGraphNode(section)).ToArray();
+ var edges = new TrackAuthoringGraphEdge[System.Math.Max(0, sections.Length - 1)];
+ for (int i = 1; i < sections.Length; i++)
+ {
+ edges[i - 1] = new TrackAuthoringGraphEdge(sections[i - 1].Id, sections[i].Id);
+ }
+
+ return new TrackAuthoringGraph(nodes, edges);
+ }
+
+ private static string[] OrderedIds(TrackAuthoringGraph graph)
+ {
+ TrackAuthoringGraphRouteResult route = TrackAuthoringGraphRouteValidator.Validate(graph);
+ Assert.True(route.Success);
+ return route.OrderedNodes.Select(node => node.Id).ToArray();
+ }
+
+ private static void AssertVectorNear(Quantum.Math.Vector3d expected, Quantum.Math.Vector3d actual)
+ {
+ Assert.InRange(System.Math.Abs(expected.X - actual.X), 0.0, Tolerance);
+ Assert.InRange(System.Math.Abs(expected.Y - actual.Y), 0.0, Tolerance);
+ Assert.InRange(System.Math.Abs(expected.Z - actual.Z), 0.0, Tolerance);
+ }
+
+ private sealed class TestCompoundOperation : ITrackAuthoringCandidateOperation
+ {
+ private readonly Func transform;
+
+ public TestCompoundOperation(Func transform)
+ {
+ this.transform = transform;
+ }
+
+ public string OperationTypeId => "test.compound";
+
+ public TrackAuthoringGraph Apply(TrackAuthoringGraph sourceGraph)
+ {
+ return transform(sourceGraph);
+ }
+ }
+}
diff --git a/Quantum.Tests/Track/TrackAuthoringGraphOperationsTests.cs b/Quantum.Tests/Track/TrackAuthoringGraphOperationsTests.cs
new file mode 100644
index 0000000..e9b76a8
--- /dev/null
+++ b/Quantum.Tests/Track/TrackAuthoringGraphOperationsTests.cs
@@ -0,0 +1,256 @@
+using Quantum.Track.Authoring;
+
+namespace Quantum.Tests;
+
+public sealed class TrackAuthoringGraphOperationsTests
+{
+ [Fact]
+ public void BuiltInDefinitions_ExposeStableBackendFamilyAndTypeDiscriminators()
+ {
+ TrackAuthoringSectionDefinition[] sections =
+ {
+ new StraightSectionDefinition("straight", 1.0),
+ new ConstantCurvatureSectionDefinition("arc", 1.0, 20.0),
+ new CurvatureTransitionSectionDefinition("transition", 1.0, 0.0, 0.05),
+ new SpatialSectionDefinition(
+ "spatial",
+ 3.0,
+ new[]
+ {
+ new Quantum.Math.Vector3d(0.0, 0.0, 0.0),
+ new Quantum.Math.Vector3d(1.0, 0.0, 0.0),
+ new Quantum.Math.Vector3d(2.0, 0.0, 0.0),
+ new Quantum.Math.Vector3d(3.0, 0.0, 0.0)
+ })
+ };
+
+ Assert.All(
+ sections,
+ section => Assert.Equal(TrackAuthoringSectionFamily.Geometry, section.Family));
+ Assert.Equal(
+ new[]
+ {
+ TrackAuthoringSectionTypeIds.Straight,
+ TrackAuthoringSectionTypeIds.ConstantCurvature,
+ TrackAuthoringSectionTypeIds.CurvatureTransition,
+ TrackAuthoringSectionTypeIds.Spatial
+ },
+ sections.Select(section => section.TypeId));
+ Assert.Equal(
+ sections.Select(section => section.TypeId),
+ TrackAuthoringSectionCatalog.Types.Select(type => type.TypeId));
+ }
+
+ [Fact]
+ public void RouteValidator_AcceptsEmptyAuthoringRouteWhileCompilerRequiresGeometry()
+ {
+ var graph = new TrackAuthoringGraph(
+ Array.Empty(),
+ Array.Empty());
+
+ TrackAuthoringGraphRouteResult route =
+ TrackAuthoringGraphRouteValidator.Validate(graph);
+ TrackAuthoringGraphCompileResult compilation =
+ TrackAuthoringGraphCompiler.Compile(graph);
+
+ Assert.True(route.Success);
+ Assert.Empty(route.OrderedNodes);
+ Assert.False(compilation.Success);
+ Assert.Contains(
+ compilation.Diagnostics,
+ diagnostic => diagnostic.Code == TrackAuthoringGraphDiagnosticCode.EmptyGraph);
+ }
+
+ [Fact]
+ public void AppendAndInsert_ReturnNewCanonicalRoutesWithoutMutatingSource()
+ {
+ TrackAuthoringGraph empty = EmptyGraph();
+
+ TrackAuthoringGraph first = TrackAuthoringGraphOperations.Append(
+ empty,
+ new StraightSectionDefinition("a", 2.0));
+ TrackAuthoringGraph second = TrackAuthoringGraphOperations.Append(
+ first,
+ new StraightSectionDefinition("c", 2.0));
+ TrackAuthoringGraph before = TrackAuthoringGraphOperations.InsertBefore(
+ second,
+ "c",
+ new ConstantCurvatureSectionDefinition("b", 2.0, 20.0));
+ TrackAuthoringGraph after = TrackAuthoringGraphOperations.InsertAfter(
+ before,
+ "c",
+ new CurvatureTransitionSectionDefinition("d", 2.0, 0.05, 0.0));
+
+ Assert.Empty(empty.Nodes);
+ AssertRoute(first, "a");
+ AssertRoute(second, "a", "c");
+ AssertRoute(before, "a", "b", "c");
+ AssertRoute(after, "a", "b", "c", "d");
+ AssertEdges(after, ("a", "b"), ("b", "c"), ("c", "d"));
+ Assert.True(TrackAuthoringGraphCompiler.Compile(after).Success);
+ }
+
+ [Fact]
+ public void Delete_RewiresNeighborsAndCanReturnToEmptyAuthoringRoute()
+ {
+ TrackAuthoringGraph source = Graph("a", "b", "c");
+
+ TrackAuthoringGraph middleDeleted =
+ TrackAuthoringGraphOperations.Delete(source, "b");
+ TrackAuthoringGraph firstDeleted =
+ TrackAuthoringGraphOperations.Delete(middleDeleted, "a");
+ TrackAuthoringGraph empty =
+ TrackAuthoringGraphOperations.Delete(firstDeleted, "c");
+
+ AssertRoute(source, "a", "b", "c");
+ AssertRoute(middleDeleted, "a", "c");
+ AssertEdges(middleDeleted, ("a", "c"));
+ AssertRoute(firstDeleted, "c");
+ Assert.Empty(empty.Nodes);
+ Assert.Empty(empty.Edges);
+ Assert.True(TrackAuthoringGraphRouteValidator.Validate(empty).Success);
+ }
+
+ [Fact]
+ public void MoveBeforeAndAfter_PreserveNodeIdentityAndRebuildRouteOrder()
+ {
+ TrackAuthoringGraph source = Graph("a", "b", "c", "d");
+ TrackAuthoringGraphNode originalC = source.Nodes.Single(node => node.Id == "c");
+
+ TrackAuthoringGraph before =
+ TrackAuthoringGraphOperations.MoveBefore(source, "d", "b");
+ TrackAuthoringGraph after =
+ TrackAuthoringGraphOperations.MoveAfter(before, "a", "c");
+
+ AssertRoute(before, "a", "d", "b", "c");
+ AssertRoute(after, "d", "b", "c", "a");
+ Assert.Same(originalC, after.Nodes.Single(node => node.Id == "c"));
+ Assert.Same(
+ after,
+ TrackAuthoringGraphOperations.MoveAfter(after, "a", "a"));
+ }
+
+ [Fact]
+ public void Replace_PreservesStableIdentityAndCanChangeTypedPayload()
+ {
+ TrackAuthoringGraph source = Graph("section");
+ var replacement = new ConstantCurvatureSectionDefinition(
+ "section",
+ 8.0,
+ -30.0,
+ 0.2);
+
+ TrackAuthoringGraph changed = TrackAuthoringGraphOperations.Replace(
+ source,
+ "section",
+ replacement);
+
+ Assert.Equal("section", Assert.Single(changed.Nodes).Id);
+ Assert.Equal(
+ TrackAuthoringSectionTypeIds.ConstantCurvature,
+ Assert.Single(changed.Nodes).TypeId);
+ Assert.Same(replacement, Assert.Single(changed.Nodes).Section);
+ Assert.IsType(Assert.Single(source.Nodes).Section);
+ }
+
+ [Fact]
+ public void Operations_RejectDuplicateMissingAndInvalidSourceRoutes()
+ {
+ TrackAuthoringGraph source = Graph("a", "b");
+ var invalidSource = new TrackAuthoringGraph(
+ source.Nodes,
+ source.Edges.Concat(new[] { new TrackAuthoringGraphEdge("a", "missing") }));
+
+ Assert.Throws(() =>
+ TrackAuthoringGraphOperations.Append(
+ source,
+ new StraightSectionDefinition("a", 1.0)));
+ Assert.Throws(() =>
+ TrackAuthoringGraphOperations.InsertBefore(
+ source,
+ "missing",
+ new StraightSectionDefinition("c", 1.0)));
+ Assert.Throws(() =>
+ TrackAuthoringGraphOperations.Delete(source, "missing"));
+ Assert.Throws(() =>
+ TrackAuthoringGraphOperations.Append(
+ invalidSource,
+ new StraightSectionDefinition("c", 1.0)));
+ }
+
+ [Fact]
+ public void FutureFamilyPayload_CanParticipateInRouteOperationsButRequiresItsOwnCompiler()
+ {
+ TrackAuthoringGraph graph = TrackAuthoringGraphOperations.Append(
+ EmptyGraph(),
+ new TestForceSectionDefinition("force"));
+
+ TrackAuthoringGraphRouteResult route =
+ TrackAuthoringGraphRouteValidator.Validate(graph);
+ TrackAuthoringGraphCompileResult compilation =
+ TrackAuthoringGraphCompiler.Compile(graph);
+
+ Assert.True(route.Success);
+ TrackAuthoringGraphNode node = Assert.Single(route.OrderedNodes);
+ Assert.Equal(TrackAuthoringSectionFamily.Force, node.Family);
+ Assert.Equal("force.test", node.TypeId);
+ Assert.False(compilation.Success);
+ TrackAuthoringGraphDiagnostic diagnostic = Assert.Single(compilation.Diagnostics);
+ Assert.Equal(TrackAuthoringGraphDiagnosticCode.UnsupportedSectionFamily, diagnostic.Code);
+ Assert.Equal("force", diagnostic.NodeId);
+ }
+
+ private static TrackAuthoringGraph EmptyGraph()
+ {
+ return new TrackAuthoringGraph(
+ Array.Empty(),
+ Array.Empty());
+ }
+
+ private static TrackAuthoringGraph Graph(params string[] ids)
+ {
+ var nodes = ids
+ .Select(id => new TrackAuthoringGraphNode(
+ new StraightSectionDefinition(id, 1.0)))
+ .ToArray();
+ var edges = new TrackAuthoringGraphEdge[System.Math.Max(0, ids.Length - 1)];
+ for (int i = 1; i < ids.Length; i++)
+ {
+ edges[i - 1] = new TrackAuthoringGraphEdge(ids[i - 1], ids[i]);
+ }
+
+ return new TrackAuthoringGraph(nodes, edges);
+ }
+
+ private static void AssertRoute(TrackAuthoringGraph graph, params string[] expectedIds)
+ {
+ TrackAuthoringGraphRouteResult result =
+ TrackAuthoringGraphRouteValidator.Validate(graph);
+ Assert.True(
+ result.Success,
+ string.Join(" ", result.Diagnostics.Select(diagnostic => diagnostic.Message)));
+ Assert.Equal(expectedIds, result.OrderedNodes.Select(node => node.Id));
+ }
+
+ private static void AssertEdges(
+ TrackAuthoringGraph graph,
+ params (string Source, string Target)[] expected)
+ {
+ Assert.Equal(expected.Length, graph.Edges.Count);
+ for (int i = 0; i < expected.Length; i++)
+ {
+ Assert.Equal(expected[i].Source, graph.Edges[i].SourceNodeId);
+ Assert.Equal(expected[i].Target, graph.Edges[i].TargetNodeId);
+ }
+ }
+
+ private sealed class TestForceSectionDefinition : TrackAuthoringSectionDefinition
+ {
+ public TestForceSectionDefinition(string id)
+ : base(id, TrackAuthoringSectionFamily.Force)
+ {
+ }
+
+ public override string TypeId => "force.test";
+ }
+}
diff --git a/Quantum.Tests/Track/TrackAuthoringScalarCurvatureTests.cs b/Quantum.Tests/Track/TrackAuthoringScalarCurvatureTests.cs
new file mode 100644
index 0000000..ad5b3d1
--- /dev/null
+++ b/Quantum.Tests/Track/TrackAuthoringScalarCurvatureTests.cs
@@ -0,0 +1,167 @@
+using Quantum.Math;
+using Quantum.Track.Authoring;
+
+namespace Quantum.Tests;
+
+public sealed class TrackAuthoringScalarCurvatureTests
+{
+ [Theory]
+ [InlineData(25.0)]
+ [InlineData(-25.0)]
+ [InlineData(0.125)]
+ [InlineData(-0.125)]
+ public void SignedRadiusAndCurvature_RoundTrip(double radius)
+ {
+ double curvature = TrackAuthoringScalarCurvature.FromSignedRadius(radius);
+ double roundTrip = TrackAuthoringScalarCurvature.ToSignedRadius(curvature);
+
+ Assert.Equal(radius, roundTrip, 12);
+ Assert.Equal(System.Math.Sign(radius), System.Math.Sign(curvature));
+ }
+
+ [Fact]
+ public void ZeroCurvature_IsStraightAndHasNoFiniteRadius()
+ {
+ Assert.Equal(
+ TrackAuthoringCurvatureDirection.Straight,
+ TrackAuthoringScalarCurvature.DirectionOf(0.0));
+ Assert.Equal(
+ 0.0,
+ TrackAuthoringScalarCurvature.FromMagnitudeAndDirection(
+ 0.0,
+ TrackAuthoringCurvatureDirection.Straight));
+ Assert.Throws(() =>
+ TrackAuthoringScalarCurvature.ToSignedRadius(0.0));
+ }
+
+ [Fact]
+ public void DirectionAndMagnitude_PreserveSignedConvention()
+ {
+ Assert.Equal(
+ TrackAuthoringCurvatureDirection.Positive,
+ TrackAuthoringScalarCurvature.DirectionOf(0.04));
+ Assert.Equal(
+ TrackAuthoringCurvatureDirection.Negative,
+ TrackAuthoringScalarCurvature.DirectionOf(-0.04));
+ Assert.Equal(
+ 0.04,
+ TrackAuthoringScalarCurvature.FromMagnitudeAndDirection(
+ 0.04,
+ TrackAuthoringCurvatureDirection.Positive));
+ Assert.Equal(
+ -0.04,
+ TrackAuthoringScalarCurvature.FromMagnitudeAndDirection(
+ 0.04,
+ TrackAuthoringCurvatureDirection.Negative));
+ }
+
+ [Theory]
+ [InlineData(0.0)]
+ [InlineData(double.NaN)]
+ [InlineData(double.PositiveInfinity)]
+ [InlineData(double.NegativeInfinity)]
+ public void FromSignedRadius_RejectsInvalidNumericInputs(double radius)
+ {
+ Assert.Throws(() =>
+ TrackAuthoringScalarCurvature.FromSignedRadius(radius));
+ }
+
+ [Theory]
+ [InlineData(0.0)]
+ [InlineData(double.NaN)]
+ [InlineData(double.PositiveInfinity)]
+ [InlineData(double.NegativeInfinity)]
+ public void ToSignedRadius_RejectsInvalidNumericInputs(double curvature)
+ {
+ Assert.Throws(() =>
+ TrackAuthoringScalarCurvature.ToSignedRadius(curvature));
+ }
+
+ [Fact]
+ public void DirectionAndMagnitude_RejectInvalidOrInconsistentInputs()
+ {
+ Assert.Throws(() =>
+ TrackAuthoringScalarCurvature.DirectionOf(double.NaN));
+ Assert.Throws(() =>
+ TrackAuthoringScalarCurvature.DirectionOf(double.PositiveInfinity));
+ Assert.Throws(() =>
+ TrackAuthoringScalarCurvature.FromMagnitudeAndDirection(
+ double.NaN,
+ TrackAuthoringCurvatureDirection.Positive));
+ Assert.Throws(() =>
+ TrackAuthoringScalarCurvature.FromMagnitudeAndDirection(
+ double.PositiveInfinity,
+ TrackAuthoringCurvatureDirection.Positive));
+ Assert.Throws(() =>
+ TrackAuthoringScalarCurvature.FromMagnitudeAndDirection(
+ -0.1,
+ TrackAuthoringCurvatureDirection.Negative));
+ Assert.Throws(() =>
+ TrackAuthoringScalarCurvature.FromMagnitudeAndDirection(
+ 0.1,
+ TrackAuthoringCurvatureDirection.Straight));
+ Assert.Throws(() =>
+ TrackAuthoringScalarCurvature.FromMagnitudeAndDirection(
+ 0.0,
+ TrackAuthoringCurvatureDirection.Positive));
+ Assert.Throws(() =>
+ TrackAuthoringScalarCurvature.FromMagnitudeAndDirection(
+ 0.1,
+ (TrackAuthoringCurvatureDirection)99));
+ }
+
+ [Fact]
+ public void EndpointExtraction_IsExactForSupportedPlanarSections()
+ {
+ var straight = new StraightSectionDefinition("straight", 5.0);
+ var arc = new ConstantCurvatureSectionDefinition("arc", 5.0, -20.0);
+ var transition = new CurvatureTransitionSectionDefinition(
+ "transition",
+ 5.0,
+ -0.05,
+ 0.08);
+
+ AssertEndpoint(straight, 0.0, 0.0);
+ AssertEndpoint(arc, -0.05, -0.05);
+ AssertEndpoint(transition, -0.05, 0.08);
+ }
+
+ [Fact]
+ public void SpatialEndpointExtraction_ReportsUnavailableWithoutApproximation()
+ {
+ var spatial = new SpatialSectionDefinition(
+ "spatial",
+ 3.0,
+ new[]
+ {
+ Vector3d.Zero,
+ new Vector3d(1.0, 0.0, 0.0),
+ new Vector3d(2.0, 1.0, 0.0),
+ new Vector3d(3.0, 1.0, 1.0)
+ });
+
+ Assert.False(TrackAuthoringScalarCurvature.TryGetStartCurvature(
+ spatial,
+ out double start));
+ Assert.False(TrackAuthoringScalarCurvature.TryGetEndCurvature(
+ spatial,
+ out double end));
+ Assert.Equal(0.0, start);
+ Assert.Equal(0.0, end);
+ }
+
+ private static void AssertEndpoint(
+ TrackAuthoringSectionDefinition section,
+ double expectedStart,
+ double expectedEnd)
+ {
+ Assert.True(TrackAuthoringScalarCurvature.TryGetStartCurvature(
+ section,
+ out double start));
+ Assert.True(TrackAuthoringScalarCurvature.TryGetEndCurvature(
+ section,
+ out double end));
+ Assert.Equal(expectedStart, start, 12);
+ Assert.Equal(expectedEnd, end, 12);
+ }
+}
diff --git a/Quantum.Tests/Track/TrackAuthoringSectionDefaultFactoryTests.cs b/Quantum.Tests/Track/TrackAuthoringSectionDefaultFactoryTests.cs
new file mode 100644
index 0000000..87377d0
--- /dev/null
+++ b/Quantum.Tests/Track/TrackAuthoringSectionDefaultFactoryTests.cs
@@ -0,0 +1,260 @@
+using Quantum.Math;
+using Quantum.Track.Authoring;
+
+namespace Quantum.Tests;
+
+public sealed class TrackAuthoringSectionDefaultFactoryTests
+{
+ [Fact]
+ public void EmptyAppend_UsesDeterministicCatalogDefaults()
+ {
+ TrackAuthoringGraph empty = Graph();
+
+ TrackAuthoringSectionDefaults straight = TrackAuthoringSectionDefaultFactory.ForAppend(
+ empty,
+ TrackAuthoringSectionTypeIds.Straight,
+ "straight");
+ TrackAuthoringSectionDefaults arc = TrackAuthoringSectionDefaultFactory.ForAppend(
+ empty,
+ TrackAuthoringSectionTypeIds.ConstantCurvature,
+ "arc");
+ TrackAuthoringSectionDefaults transition = TrackAuthoringSectionDefaultFactory.ForAppend(
+ empty,
+ TrackAuthoringSectionTypeIds.CurvatureTransition,
+ "transition");
+
+ Assert.Equal(0.0, straight.InsertionStation);
+ Assert.Null(straight.UpstreamNodeId);
+ Assert.Null(straight.DownstreamNodeId);
+ Assert.Equal(10.0, Assert.IsType(straight.Definition).Length);
+ Assert.Equal(0.0, straight.InheritedRollRadians);
+ Assert.True(straight.Flags.HasFlag(TrackAuthoringSectionDefaultFlags.ZeroRollFallback));
+
+ ConstantCurvatureSectionDefinition constant =
+ Assert.IsType(arc.Definition);
+ Assert.Equal(10.0, constant.Length);
+ Assert.Equal(25.0, constant.Radius);
+ Assert.True(arc.Flags.HasFlag(TrackAuthoringSectionDefaultFlags.PositiveRadiusFallback));
+
+ CurvatureTransitionSectionDefinition curve =
+ Assert.IsType(transition.Definition);
+ Assert.Equal(0.0, curve.StartCurvature);
+ Assert.Equal(0.0, curve.EndCurvature);
+ Assert.True(transition.Flags.HasFlag(
+ TrackAuthoringSectionDefaultFlags.ZeroCurvatureFallback));
+ }
+
+ [Fact]
+ public void ConstantCurvature_UsesNearestSupportedNonzeroNeighbor()
+ {
+ TrackAuthoringGraph graph = Graph(
+ new StraightSectionDefinition("entry", 4.0, 0.1),
+ new ConstantCurvatureSectionDefinition("turn", 6.0, -20.0, 0.2));
+
+ TrackAuthoringSectionDefaults beforeTurn =
+ TrackAuthoringSectionDefaultFactory.ForInsertBefore(
+ graph,
+ "turn",
+ TrackAuthoringSectionTypeIds.ConstantCurvature,
+ "inserted");
+
+ ConstantCurvatureSectionDefinition definition =
+ Assert.IsType(beforeTurn.Definition);
+ Assert.Equal(-20.0, definition.Radius, 12);
+ Assert.Equal(4.0, beforeTurn.InsertionStation);
+ Assert.Equal("entry", beforeTurn.UpstreamNodeId);
+ Assert.Equal("turn", beforeTurn.DownstreamNodeId);
+ Assert.Equal(0.0, beforeTurn.UpstreamEndCurvature);
+ Assert.Equal(-0.05, beforeTurn.DownstreamStartCurvature!.Value, 12);
+ Assert.True(beforeTurn.Flags.HasFlag(
+ TrackAuthoringSectionDefaultFlags.CurvatureInheritedFromDownstream));
+ }
+
+ [Fact]
+ public void AppendAndReplacement_UseCorrectRoutePositionContext()
+ {
+ TrackAuthoringGraph graph = Graph(
+ new ConstantCurvatureSectionDefinition("a", 3.0, 50.0, 0.1),
+ new StraightSectionDefinition("b", 7.0, 0.2),
+ new ConstantCurvatureSectionDefinition("c", 5.0, -25.0, 0.3));
+
+ TrackAuthoringSectionDefaults append = TrackAuthoringSectionDefaultFactory.ForAppend(
+ graph,
+ TrackAuthoringSectionTypeIds.CurvatureTransition,
+ "tail");
+ TrackAuthoringSectionDefaults replacement =
+ TrackAuthoringSectionDefaultFactory.ForReplacement(
+ graph,
+ "b",
+ TrackAuthoringSectionTypeIds.CurvatureTransition);
+
+ CurvatureTransitionSectionDefinition appended =
+ Assert.IsType(append.Definition);
+ Assert.Equal(15.0, append.InsertionStation);
+ Assert.Equal("c", append.UpstreamNodeId);
+ Assert.Null(append.DownstreamNodeId);
+ Assert.Equal(-0.04, appended.StartCurvature, 12);
+ Assert.Equal(-0.04, appended.EndCurvature, 12);
+
+ CurvatureTransitionSectionDefinition replaced =
+ Assert.IsType(replacement.Definition);
+ Assert.Equal("b", replaced.Id);
+ Assert.Equal(3.0, replacement.InsertionStation);
+ Assert.Equal("a", replacement.UpstreamNodeId);
+ Assert.Equal("c", replacement.DownstreamNodeId);
+ Assert.Equal(0.02, replaced.StartCurvature, 12);
+ Assert.Equal(-0.04, replaced.EndCurvature, 12);
+ Assert.True(replacement.Flags.HasFlag(
+ TrackAuthoringSectionDefaultFlags.TransitionBridgesNeighbors));
+ }
+
+ [Fact]
+ public void Transition_BridgesBothSupportedNeighborEndpoints()
+ {
+ TrackAuthoringGraph graph = Graph(
+ new CurvatureTransitionSectionDefinition("in", 6.0, 0.0, 0.05),
+ new ConstantCurvatureSectionDefinition("out", 8.0, -10.0));
+
+ TrackAuthoringSectionDefaults defaults =
+ TrackAuthoringSectionDefaultFactory.ForInsertAfter(
+ graph,
+ "in",
+ TrackAuthoringSectionTypeIds.CurvatureTransition,
+ "bridge");
+
+ CurvatureTransitionSectionDefinition bridge =
+ Assert.IsType(defaults.Definition);
+ Assert.Equal(0.05, bridge.StartCurvature, 12);
+ Assert.Equal(-0.1, bridge.EndCurvature, 12);
+ Assert.True(defaults.Flags.HasFlag(
+ TrackAuthoringSectionDefaultFlags.TransitionBridgesNeighbors));
+ }
+
+ [Fact]
+ public void Roll_InheritsUpstreamThenDownstreamThenZero()
+ {
+ TrackAuthoringGraph graph = Graph(
+ new StraightSectionDefinition("up", 4.0, 0.25),
+ new StraightSectionDefinition("down", 5.0, -0.5));
+
+ TrackAuthoringSectionDefaults between =
+ TrackAuthoringSectionDefaultFactory.ForInsertBefore(
+ graph,
+ "down",
+ TrackAuthoringSectionTypeIds.Straight,
+ "between");
+ TrackAuthoringSectionDefaults atStart =
+ TrackAuthoringSectionDefaultFactory.ForInsertBefore(
+ graph,
+ "up",
+ TrackAuthoringSectionTypeIds.Straight,
+ "start");
+
+ Assert.Equal(0.25, between.InheritedRollRadians);
+ Assert.Equal(0.25, Assert.IsType(between.Definition).RollRadians);
+ Assert.True(between.Flags.HasFlag(
+ TrackAuthoringSectionDefaultFlags.RollInheritedFromUpstream));
+ Assert.Equal(0.25, atStart.InheritedRollRadians);
+ Assert.True(atStart.Flags.HasFlag(
+ TrackAuthoringSectionDefaultFlags.RollInheritedFromDownstream));
+ }
+
+ [Fact]
+ public void SpatialNeighbor_ReportsScalarUnavailableAndUsesDocumentedFallback()
+ {
+ var spatial = new SpatialSectionDefinition(
+ "spatial",
+ 3.0,
+ new[]
+ {
+ Vector3d.Zero,
+ new Vector3d(1.0, 0.0, 0.0),
+ new Vector3d(2.0, 1.0, 0.0),
+ new Vector3d(3.0, 1.0, 1.0)
+ },
+ rollRadians: 0.4);
+ TrackAuthoringGraph graph = Graph(spatial);
+
+ TrackAuthoringSectionDefaults arc = TrackAuthoringSectionDefaultFactory.ForAppend(
+ graph,
+ TrackAuthoringSectionTypeIds.ConstantCurvature,
+ "arc");
+ TrackAuthoringSectionDefaults transition = TrackAuthoringSectionDefaultFactory.ForAppend(
+ graph,
+ TrackAuthoringSectionTypeIds.CurvatureTransition,
+ "transition");
+
+ Assert.Null(arc.UpstreamEndCurvature);
+ Assert.Equal(0.4, arc.InheritedRollRadians);
+ Assert.Equal(25.0, Assert.IsType(arc.Definition).Radius);
+ Assert.True(arc.Flags.HasFlag(
+ TrackAuthoringSectionDefaultFlags.UpstreamScalarCurvatureUnavailable));
+ Assert.True(arc.Flags.HasFlag(
+ TrackAuthoringSectionDefaultFlags.PositiveRadiusFallback));
+
+ CurvatureTransitionSectionDefinition transitionDefinition =
+ Assert.IsType(transition.Definition);
+ Assert.Equal(0.0, transitionDefinition.StartCurvature);
+ Assert.Equal(0.0, transitionDefinition.EndCurvature);
+ Assert.True(transition.Flags.HasFlag(
+ TrackAuthoringSectionDefaultFlags.ZeroCurvatureFallback));
+ }
+
+ [Fact]
+ public void MixedFamilyRoute_ReturnsAvailableContextWithoutAssumingGeometryOnly()
+ {
+ TrackAuthoringGraph graph = Graph(
+ new StraightSectionDefinition("entry", 4.0, 0.1),
+ new TestForceSectionDefinition("force"),
+ new ConstantCurvatureSectionDefinition("exit", 5.0, -20.0, 0.3));
+
+ TrackAuthoringSectionDefaults defaults =
+ TrackAuthoringSectionDefaultFactory.ForInsertAfter(
+ graph,
+ "force",
+ TrackAuthoringSectionTypeIds.CurvatureTransition,
+ "transition");
+
+ CurvatureTransitionSectionDefinition transition =
+ Assert.IsType(defaults.Definition);
+ Assert.False(defaults.HasInsertionStation);
+ Assert.Null(defaults.InsertionStation);
+ Assert.Equal("force", defaults.UpstreamNodeId);
+ Assert.Equal("exit", defaults.DownstreamNodeId);
+ Assert.Null(defaults.UpstreamEndCurvature);
+ Assert.Equal(-0.05, defaults.DownstreamStartCurvature!.Value, 12);
+ Assert.Equal(-0.05, transition.StartCurvature, 12);
+ Assert.Equal(-0.05, transition.EndCurvature, 12);
+ Assert.Equal(0.3, defaults.InheritedRollRadians);
+ Assert.True(defaults.Flags.HasFlag(
+ TrackAuthoringSectionDefaultFlags.InsertionStationUnavailable));
+ Assert.True(defaults.Flags.HasFlag(
+ TrackAuthoringSectionDefaultFlags.UpstreamScalarCurvatureUnavailable));
+ Assert.True(defaults.Flags.HasFlag(
+ TrackAuthoringSectionDefaultFlags.UpstreamRollUnavailable));
+ Assert.True(defaults.Flags.HasFlag(
+ TrackAuthoringSectionDefaultFlags.RollInheritedFromDownstream));
+ }
+
+ private static TrackAuthoringGraph Graph(params TrackAuthoringSectionDefinition[] sections)
+ {
+ var nodes = sections.Select(section => new TrackAuthoringGraphNode(section)).ToArray();
+ var edges = new TrackAuthoringGraphEdge[System.Math.Max(0, sections.Length - 1)];
+ for (int i = 1; i < sections.Length; i++)
+ {
+ edges[i - 1] = new TrackAuthoringGraphEdge(sections[i - 1].Id, sections[i].Id);
+ }
+
+ return new TrackAuthoringGraph(nodes, edges);
+ }
+
+ private sealed class TestForceSectionDefinition : TrackAuthoringSectionDefinition
+ {
+ public TestForceSectionDefinition(string id)
+ : base(id, TrackAuthoringSectionFamily.Force)
+ {
+ }
+
+ public override string TypeId => "force.test";
+ }
+}
diff --git a/Quantum.Track/Authoring/ConstantCurvatureSectionDefinition.cs b/Quantum.Track/Authoring/ConstantCurvatureSectionDefinition.cs
index 572e703..ed9cee8 100644
--- a/Quantum.Track/Authoring/ConstantCurvatureSectionDefinition.cs
+++ b/Quantum.Track/Authoring/ConstantCurvatureSectionDefinition.cs
@@ -18,19 +18,10 @@ public ConstantCurvatureSectionDefinition(
double rollRadians = 0.0)
: base(id, length, rollRadians)
{
- if (!AuthoringValidation.IsFinite(radius) || radius == 0.0)
- {
- throw new ArgumentOutOfRangeException(
- nameof(radius),
- radius,
- "Section radius must be finite and non-zero.");
- }
-
- double curvature = 1.0 / radius;
+ double curvature = TrackAuthoringScalarCurvature.FromSignedRadius(radius);
double sweepRadians = length * curvature;
- if (!AuthoringValidation.IsFinite(curvature) ||
- !AuthoringValidation.IsFinite(sweepRadians))
+ if (!AuthoringValidation.IsFinite(sweepRadians))
{
throw new ArgumentOutOfRangeException(
nameof(radius),
@@ -50,5 +41,7 @@ public ConstantCurvatureSectionDefinition(
/// Alias that makes the signed-radius convention explicit.
///
public double SignedRadius => Radius;
+
+ public override string TypeId => TrackAuthoringSectionTypeIds.ConstantCurvature;
}
}
diff --git a/Quantum.Track/Authoring/CurvatureTransitionSectionDefinition.cs b/Quantum.Track/Authoring/CurvatureTransitionSectionDefinition.cs
index 7e37818..6215b29 100644
--- a/Quantum.Track/Authoring/CurvatureTransitionSectionDefinition.cs
+++ b/Quantum.Track/Authoring/CurvatureTransitionSectionDefinition.cs
@@ -69,5 +69,7 @@ public CurvatureTransitionSectionDefinition(
/// Distance-domain interpolation applied between the endpoint curvatures.
///
public CurvatureTransitionInterpolationMode InterpolationMode { get; }
+
+ public override string TypeId => TrackAuthoringSectionTypeIds.CurvatureTransition;
}
}
diff --git a/Quantum.Track/Authoring/GeometricSectionDefinition.cs b/Quantum.Track/Authoring/GeometricSectionDefinition.cs
index fb7d6b4..a5cd92c 100644
--- a/Quantum.Track/Authoring/GeometricSectionDefinition.cs
+++ b/Quantum.Track/Authoring/GeometricSectionDefinition.cs
@@ -3,14 +3,14 @@ namespace Quantum.Track.Authoring
///
/// Base authoring definition for one ordered geometric track section.
///
- public abstract class GeometricSectionDefinition
+ public abstract class GeometricSectionDefinition : TrackAuthoringSectionDefinition
{
protected GeometricSectionDefinition(
string id,
double length,
double rollRadians)
+ : base(id, TrackAuthoringSectionFamily.Geometry)
{
- Id = AuthoringValidation.RequireId(id);
Length = AuthoringValidation.RequirePositiveFinite(length, nameof(length), "Section length");
RollRadians = AuthoringValidation.RequireFinite(
rollRadians,
@@ -18,11 +18,6 @@ protected GeometricSectionDefinition(
"Section roll");
}
- ///
- /// Stable section identifier. The supplied value is preserved exactly.
- ///
- public string Id { get; }
-
///
/// Section length in station-distance units.
///
diff --git a/Quantum.Track/Authoring/Graph/TrackAuthoringCandidateEvaluation.cs b/Quantum.Track/Authoring/Graph/TrackAuthoringCandidateEvaluation.cs
new file mode 100644
index 0000000..a26b3e0
--- /dev/null
+++ b/Quantum.Track/Authoring/Graph/TrackAuthoringCandidateEvaluation.cs
@@ -0,0 +1,65 @@
+using System;
+using System.Collections.Generic;
+
+namespace Quantum.Track.Authoring
+{
+ ///
+ /// Immutable result of applying, validating, and compiling one candidate operation.
+ ///
+ public sealed class TrackAuthoringCandidateEvaluation
+ {
+ private readonly IReadOnlyList _diagnostics;
+
+ internal TrackAuthoringCandidateEvaluation(
+ TrackAuthoringGraph sourceGraph,
+ ITrackAuthoringCandidateOperation operation,
+ TrackAuthoringGraph? candidateGraph,
+ TrackAuthoringGraphRouteResult? routeResult,
+ TrackAuthoringGraphCompileResult? compileResult,
+ IEnumerable diagnostics)
+ {
+ SourceGraph = sourceGraph ?? throw new ArgumentNullException(nameof(sourceGraph));
+ Operation = operation ?? throw new ArgumentNullException(nameof(operation));
+ CandidateGraph = candidateGraph;
+ RouteResult = routeResult;
+ CompileResult = compileResult;
+ Compilation = compileResult?.Compilation;
+ _diagnostics = new List(
+ diagnostics ?? throw new ArgumentNullException(nameof(diagnostics))).AsReadOnly();
+ }
+
+ public TrackAuthoringGraph SourceGraph { get; }
+
+ public ITrackAuthoringCandidateOperation Operation { get; }
+
+ public TrackAuthoringGraph? CandidateGraph { get; }
+
+ public TrackAuthoringGraphRouteResult? RouteResult { get; }
+
+ public TrackAuthoringGraphCompileResult? CompileResult { get; }
+
+ public TrackAuthoringCompilation? Compilation { get; }
+
+ public IReadOnlyList Diagnostics => _diagnostics;
+
+ public bool CommitEligible =>
+ CandidateGraph != null &&
+ RouteResult?.Success == true &&
+ CompileResult?.Success == true &&
+ Compilation != null;
+
+ ///
+ /// Conservatively detects whether the active immutable graph snapshot changed
+ /// since this result was evaluated.
+ ///
+ public bool IsStaleComparedTo(TrackAuthoringGraph currentGraph)
+ {
+ if (currentGraph is null)
+ {
+ throw new ArgumentNullException(nameof(currentGraph));
+ }
+
+ return !ReferenceEquals(SourceGraph, currentGraph);
+ }
+ }
+}
diff --git a/Quantum.Track/Authoring/Graph/TrackAuthoringCandidateEvaluator.cs b/Quantum.Track/Authoring/Graph/TrackAuthoringCandidateEvaluator.cs
new file mode 100644
index 0000000..2200b13
--- /dev/null
+++ b/Quantum.Track/Authoring/Graph/TrackAuthoringCandidateEvaluator.cs
@@ -0,0 +1,86 @@
+using System;
+using System.Collections.Generic;
+
+namespace Quantum.Track.Authoring
+{
+ ///
+ /// Pure production-backed evaluator for a single non-destructive graph candidate.
+ ///
+ public static class TrackAuthoringCandidateEvaluator
+ {
+ public static TrackAuthoringCandidateEvaluation Evaluate(
+ TrackAuthoringGraph sourceGraph,
+ ITrackAuthoringCandidateOperation operation)
+ {
+ return Evaluate(sourceGraph, operation, TrackAuthoringGraphCompiler.Compile);
+ }
+
+ internal static TrackAuthoringCandidateEvaluation Evaluate(
+ TrackAuthoringGraph sourceGraph,
+ ITrackAuthoringCandidateOperation operation,
+ Func compiler)
+ {
+ if (sourceGraph is null)
+ {
+ throw new ArgumentNullException(nameof(sourceGraph));
+ }
+
+ if (operation is null)
+ {
+ throw new ArgumentNullException(nameof(operation));
+ }
+
+ if (compiler is null)
+ {
+ throw new ArgumentNullException(nameof(compiler));
+ }
+
+ TrackAuthoringGraph candidateGraph;
+ try
+ {
+ candidateGraph = operation.Apply(sourceGraph) ??
+ throw new InvalidOperationException(
+ "A candidate operation cannot return a null graph.");
+ }
+ catch (Exception exception) when (
+ exception is ArgumentException ||
+ exception is InvalidOperationException ||
+ exception is NotSupportedException)
+ {
+ var operationDiagnostic = new TrackAuthoringGraphDiagnostic(
+ TrackAuthoringGraphDiagnosticCode.CandidateOperationFailed,
+ $"Candidate operation '{operation.OperationTypeId}' failed: {exception.Message}");
+ return new TrackAuthoringCandidateEvaluation(
+ sourceGraph,
+ operation,
+ null,
+ null,
+ null,
+ new[] { operationDiagnostic });
+ }
+
+ TrackAuthoringGraphRouteResult routeResult =
+ TrackAuthoringGraphRouteValidator.Validate(candidateGraph);
+ if (!routeResult.Success)
+ {
+ return new TrackAuthoringCandidateEvaluation(
+ sourceGraph,
+ operation,
+ candidateGraph,
+ routeResult,
+ null,
+ routeResult.Diagnostics);
+ }
+
+ TrackAuthoringGraphCompileResult compileResult = compiler(candidateGraph);
+ IReadOnlyList diagnostics = compileResult.Diagnostics;
+ return new TrackAuthoringCandidateEvaluation(
+ sourceGraph,
+ operation,
+ candidateGraph,
+ routeResult,
+ compileResult,
+ diagnostics);
+ }
+ }
+}
diff --git a/Quantum.Track/Authoring/Graph/TrackAuthoringCandidateOperation.cs b/Quantum.Track/Authoring/Graph/TrackAuthoringCandidateOperation.cs
new file mode 100644
index 0000000..939f4bb
--- /dev/null
+++ b/Quantum.Track/Authoring/Graph/TrackAuthoringCandidateOperation.cs
@@ -0,0 +1,161 @@
+using System;
+
+namespace Quantum.Track.Authoring
+{
+ ///
+ /// Pure provisional transformation of one immutable authoring graph snapshot.
+ ///
+ ///
+ /// Implementations may represent one section edit or a future compound timeline
+ /// transformation. Candidate evaluation depends only on this contract, so adding
+ /// trim, split, range, or multi-parameter operations does not require a new evaluator.
+ ///
+ public interface ITrackAuthoringCandidateOperation
+ {
+ string OperationTypeId { get; }
+
+ TrackAuthoringGraph Apply(TrackAuthoringGraph sourceGraph);
+ }
+
+ public enum TrackAuthoringCandidateOperationKind
+ {
+ Append = 0,
+ InsertBefore = 1,
+ InsertAfter = 2,
+ Replace = 3
+ }
+
+ ///
+ /// Typed immutable graph operation carrying an actual production section definition.
+ ///
+ public sealed class TrackAuthoringCandidateOperation : ITrackAuthoringCandidateOperation
+ {
+ public const string AppendOperationTypeId = "graph.appendSection";
+ public const string InsertBeforeOperationTypeId = "graph.insertSectionBefore";
+ public const string InsertAfterOperationTypeId = "graph.insertSectionAfter";
+ public const string ReplaceOperationTypeId = "graph.replaceSection";
+
+ private TrackAuthoringCandidateOperation(
+ TrackAuthoringCandidateOperationKind kind,
+ string? targetNodeId,
+ TrackAuthoringSectionDefinition section)
+ {
+ Kind = kind;
+ TargetNodeId = targetNodeId;
+ Section = section ?? throw new ArgumentNullException(nameof(section));
+ }
+
+ public TrackAuthoringCandidateOperationKind Kind { get; }
+
+ public string? TargetNodeId { get; }
+
+ public TrackAuthoringSectionDefinition Section { get; }
+
+ public string OperationTypeId
+ {
+ get
+ {
+ switch (Kind)
+ {
+ case TrackAuthoringCandidateOperationKind.Append:
+ return AppendOperationTypeId;
+
+ case TrackAuthoringCandidateOperationKind.InsertBefore:
+ return InsertBeforeOperationTypeId;
+
+ case TrackAuthoringCandidateOperationKind.InsertAfter:
+ return InsertAfterOperationTypeId;
+
+ case TrackAuthoringCandidateOperationKind.Replace:
+ return ReplaceOperationTypeId;
+
+ default:
+ throw new NotSupportedException(
+ $"Candidate operation kind '{Kind}' is not supported.");
+ }
+ }
+ }
+
+ public TrackAuthoringGraph Apply(TrackAuthoringGraph sourceGraph)
+ {
+ switch (Kind)
+ {
+ case TrackAuthoringCandidateOperationKind.Append:
+ return TrackAuthoringGraphOperations.Append(sourceGraph, Section);
+
+ case TrackAuthoringCandidateOperationKind.InsertBefore:
+ return TrackAuthoringGraphOperations.InsertBefore(
+ sourceGraph,
+ TargetNodeId!,
+ Section);
+
+ case TrackAuthoringCandidateOperationKind.InsertAfter:
+ return TrackAuthoringGraphOperations.InsertAfter(
+ sourceGraph,
+ TargetNodeId!,
+ Section);
+
+ case TrackAuthoringCandidateOperationKind.Replace:
+ return TrackAuthoringGraphOperations.Replace(
+ sourceGraph,
+ TargetNodeId!,
+ Section);
+
+ default:
+ throw new NotSupportedException(
+ $"Candidate operation kind '{Kind}' is not supported.");
+ }
+ }
+
+ public static TrackAuthoringCandidateOperation Append(
+ TrackAuthoringSectionDefinition section)
+ {
+ return new TrackAuthoringCandidateOperation(
+ TrackAuthoringCandidateOperationKind.Append,
+ null,
+ section);
+ }
+
+ public static TrackAuthoringCandidateOperation InsertBefore(
+ string anchorNodeId,
+ TrackAuthoringSectionDefinition section)
+ {
+ return new TrackAuthoringCandidateOperation(
+ TrackAuthoringCandidateOperationKind.InsertBefore,
+ RequireTargetNodeId(anchorNodeId, nameof(anchorNodeId)),
+ section);
+ }
+
+ public static TrackAuthoringCandidateOperation InsertAfter(
+ string anchorNodeId,
+ TrackAuthoringSectionDefinition section)
+ {
+ return new TrackAuthoringCandidateOperation(
+ TrackAuthoringCandidateOperationKind.InsertAfter,
+ RequireTargetNodeId(anchorNodeId, nameof(anchorNodeId)),
+ section);
+ }
+
+ public static TrackAuthoringCandidateOperation Replace(
+ string nodeId,
+ TrackAuthoringSectionDefinition section)
+ {
+ return new TrackAuthoringCandidateOperation(
+ TrackAuthoringCandidateOperationKind.Replace,
+ RequireTargetNodeId(nodeId, nameof(nodeId)),
+ section);
+ }
+
+ private static string RequireTargetNodeId(string nodeId, string parameterName)
+ {
+ if (string.IsNullOrWhiteSpace(nodeId))
+ {
+ throw new ArgumentException(
+ "A candidate operation target node ID is required.",
+ parameterName);
+ }
+
+ return nodeId;
+ }
+ }
+}
diff --git a/Quantum.Track/Authoring/Graph/TrackAuthoringGraph.cs b/Quantum.Track/Authoring/Graph/TrackAuthoringGraph.cs
index 28922df..3ab51ad 100644
--- a/Quantum.Track/Authoring/Graph/TrackAuthoringGraph.cs
+++ b/Quantum.Track/Authoring/Graph/TrackAuthoringGraph.cs
@@ -80,7 +80,7 @@ public TrackAuthoringGraph(
///
public TrackAuthoringGraph WithSection(
string nodeId,
- GeometricSectionDefinition replacement)
+ TrackAuthoringSectionDefinition replacement)
{
if (nodeId is null)
{
diff --git a/Quantum.Track/Authoring/Graph/TrackAuthoringGraphCompiler.cs b/Quantum.Track/Authoring/Graph/TrackAuthoringGraphCompiler.cs
index 3246106..6f6eee1 100644
--- a/Quantum.Track/Authoring/Graph/TrackAuthoringGraphCompiler.cs
+++ b/Quantum.Track/Authoring/Graph/TrackAuthoringGraphCompiler.cs
@@ -26,50 +26,28 @@ public static TrackAuthoringGraphCompileResult Compile(TrackAuthoringGraph graph
return Failure(diagnostics);
}
- Dictionary? nodesById = BuildNodeIndex(
- graph.Nodes,
- diagnostics);
- if (nodesById is null)
- {
- return Failure(diagnostics);
- }
-
- var incoming = new Dictionary>(StringComparer.Ordinal);
- var outgoing = new Dictionary>(StringComparer.Ordinal);
- for (int i = 0; i < graph.Nodes.Count; i++)
- {
- string nodeId = graph.Nodes[i].Id;
- incoming.Add(nodeId, new List());
- outgoing.Add(nodeId, new List());
- }
-
- AddEdges(graph.Edges, nodesById, incoming, outgoing, diagnostics);
- if (diagnostics.Count != 0)
+ TrackAuthoringGraphRouteResult route =
+ TrackAuthoringGraphRouteValidator.Validate(graph);
+ if (!route.Success)
{
- return Failure(diagnostics);
+ return Failure(route.Diagnostics, route.OrderedNodes);
}
- ValidateDegree(graph.Nodes, incoming, outgoing, diagnostics);
- if (diagnostics.Count != 0)
+ var orderedNodes = new List(route.OrderedNodes);
+ for (int i = 0; i < orderedNodes.Count; i++)
{
- return Failure(diagnostics);
- }
+ TrackAuthoringGraphNode node = orderedNodes[i];
+ if (node.Section is GeometricSectionDefinition)
+ {
+ continue;
+ }
- if (HasCycle(graph.Nodes, incoming, outgoing, out IReadOnlyList cycleNodeIds))
- {
diagnostics.Add(new TrackAuthoringGraphDiagnostic(
- TrackAuthoringGraphDiagnosticCode.CycleDetected,
- "The authoring graph contains a cycle involving node IDs: " +
- string.Join(", ", cycleNodeIds) + "."));
- return Failure(diagnostics);
+ TrackAuthoringGraphDiagnosticCode.UnsupportedSectionFamily,
+ $"Section type '{node.TypeId}' in family '{node.Family}' does not have a route compiler.",
+ nodeId: node.Id));
}
- List orderedNodes = BuildSingleRoute(
- graph.Nodes,
- nodesById,
- incoming,
- outgoing,
- diagnostics);
if (diagnostics.Count != 0)
{
return Failure(diagnostics, orderedNodes);
@@ -79,7 +57,7 @@ public static TrackAuthoringGraphCompileResult Compile(TrackAuthoringGraph graph
try
{
GeometricSectionDefinition[] sections = orderedNodes
- .Select(node => node.Section)
+ .Select(node => (GeometricSectionDefinition)node.Section)
.ToArray();
definition = graph.Banking is null
? new TrackAuthoringDefinition(sections, graph.StartPose)
@@ -120,208 +98,6 @@ exception is InvalidOperationException ||
}
}
- private static Dictionary? BuildNodeIndex(
- IReadOnlyList nodes,
- ICollection diagnostics)
- {
- var result = new Dictionary(StringComparer.Ordinal);
- for (int i = 0; i < nodes.Count; i++)
- {
- TrackAuthoringGraphNode node = nodes[i];
- if (!result.ContainsKey(node.Id))
- {
- result.Add(node.Id, node);
- continue;
- }
-
- diagnostics.Add(new TrackAuthoringGraphDiagnostic(
- TrackAuthoringGraphDiagnosticCode.DuplicateNodeId,
- $"Graph node ID '{node.Id}' is duplicated.",
- nodeId: node.Id));
- }
-
- return diagnostics.Count == 0 ? result : null;
- }
-
- private static void AddEdges(
- IReadOnlyList edges,
- IReadOnlyDictionary nodesById,
- IDictionary> incoming,
- IDictionary> outgoing,
- ICollection diagnostics)
- {
- var seenEdges = new HashSet<(string Source, string Target)>();
- for (int i = 0; i < edges.Count; i++)
- {
- TrackAuthoringGraphEdge edge = edges[i];
- bool sourceExists = nodesById.ContainsKey(edge.SourceNodeId);
- bool targetExists = nodesById.ContainsKey(edge.TargetNodeId);
-
- if (!sourceExists || !targetExists)
- {
- string missing = !sourceExists && !targetExists
- ? "source and target node IDs"
- : !sourceExists ? "source node ID" : "target node ID";
- diagnostics.Add(new TrackAuthoringGraphDiagnostic(
- TrackAuthoringGraphDiagnosticCode.UnknownEdgeEndpoint,
- $"Graph edge '{edge.SourceNodeId}' -> '{edge.TargetNodeId}' has unknown {missing}.",
- sourceNodeId: edge.SourceNodeId,
- targetNodeId: edge.TargetNodeId));
- continue;
- }
-
- if (!seenEdges.Add((edge.SourceNodeId, edge.TargetNodeId)))
- {
- diagnostics.Add(new TrackAuthoringGraphDiagnostic(
- TrackAuthoringGraphDiagnosticCode.DuplicateEdge,
- $"Graph edge '{edge.SourceNodeId}' -> '{edge.TargetNodeId}' is duplicated.",
- sourceNodeId: edge.SourceNodeId,
- targetNodeId: edge.TargetNodeId));
- continue;
- }
-
- outgoing[edge.SourceNodeId].Add(edge.TargetNodeId);
- incoming[edge.TargetNodeId].Add(edge.SourceNodeId);
- }
- }
-
- private static void ValidateDegree(
- IReadOnlyList nodes,
- IReadOnlyDictionary> incoming,
- IReadOnlyDictionary> outgoing,
- ICollection diagnostics)
- {
- for (int i = 0; i < nodes.Count; i++)
- {
- string nodeId = nodes[i].Id;
- if (incoming[nodeId].Count > 1)
- {
- diagnostics.Add(new TrackAuthoringGraphDiagnostic(
- TrackAuthoringGraphDiagnosticCode.MultipleIncomingEdges,
- $"Graph node ID '{nodeId}' has {incoming[nodeId].Count} incoming edges; merging is not supported.",
- nodeId: nodeId));
- }
-
- if (outgoing[nodeId].Count > 1)
- {
- diagnostics.Add(new TrackAuthoringGraphDiagnostic(
- TrackAuthoringGraphDiagnosticCode.MultipleOutgoingEdges,
- $"Graph node ID '{nodeId}' has {outgoing[nodeId].Count} outgoing edges; branching is not supported.",
- nodeId: nodeId));
- }
- }
- }
-
- private static bool HasCycle(
- IReadOnlyList nodes,
- IReadOnlyDictionary> incoming,
- IReadOnlyDictionary> outgoing,
- out IReadOnlyList cycleNodeIds)
- {
- var remainingIncoming = new Dictionary(StringComparer.Ordinal);
- var ready = new Queue();
- for (int i = 0; i < nodes.Count; i++)
- {
- string nodeId = nodes[i].Id;
- int count = incoming[nodeId].Count;
- remainingIncoming.Add(nodeId, count);
- if (count == 0)
- {
- ready.Enqueue(nodeId);
- }
- }
-
- int visitedCount = 0;
- while (ready.Count != 0)
- {
- string nodeId = ready.Dequeue();
- visitedCount++;
- List successors = outgoing[nodeId];
- for (int i = 0; i < successors.Count; i++)
- {
- string successor = successors[i];
- remainingIncoming[successor]--;
- if (remainingIncoming[successor] == 0)
- {
- ready.Enqueue(successor);
- }
- }
- }
-
- if (visitedCount == nodes.Count)
- {
- cycleNodeIds = Array.Empty();
- return false;
- }
-
- cycleNodeIds = nodes
- .Where(node => remainingIncoming[node.Id] > 0)
- .Select(node => node.Id)
- .ToArray();
- return true;
- }
-
- private static List BuildSingleRoute(
- IReadOnlyList nodes,
- IReadOnlyDictionary nodesById,
- IReadOnlyDictionary> incoming,
- IReadOnlyDictionary> outgoing,
- ICollection diagnostics)
- {
- string[] startNodeIds = nodes
- .Where(node => incoming[node.Id].Count == 0)
- .Select(node => node.Id)
- .ToArray();
- string[] endNodeIds = nodes
- .Where(node => outgoing[node.Id].Count == 0)
- .Select(node => node.Id)
- .ToArray();
-
- if (startNodeIds.Length != 1 || endNodeIds.Length != 1)
- {
- diagnostics.Add(new TrackAuthoringGraphDiagnostic(
- TrackAuthoringGraphDiagnosticCode.DisconnectedNode,
- "A deterministic linear graph must have exactly one start and one end node; " +
- $"found starts [{string.Join(", ", startNodeIds)}] and " +
- $"ends [{string.Join(", ", endNodeIds)}]."));
- return new List();
- }
-
- var orderedNodes = new List(nodes.Count);
- var visited = new HashSet(StringComparer.Ordinal);
- string currentNodeId = startNodeIds[0];
- while (visited.Add(currentNodeId))
- {
- orderedNodes.Add(nodesById[currentNodeId]);
- List successors = outgoing[currentNodeId];
- if (successors.Count == 0)
- {
- break;
- }
-
- currentNodeId = successors[0];
- }
-
- if (orderedNodes.Count == nodes.Count)
- {
- return orderedNodes;
- }
-
- for (int i = 0; i < nodes.Count; i++)
- {
- TrackAuthoringGraphNode node = nodes[i];
- if (!visited.Contains(node.Id))
- {
- diagnostics.Add(new TrackAuthoringGraphDiagnostic(
- TrackAuthoringGraphDiagnosticCode.DisconnectedNode,
- $"Graph node ID '{node.Id}' is disconnected from the single compiled route.",
- nodeId: node.Id));
- }
- }
-
- return orderedNodes;
- }
-
private static TrackAuthoringGraphDiagnostic CreateCompilationDiagnostic(Exception exception)
{
return new TrackAuthoringGraphDiagnostic(
diff --git a/Quantum.Track/Authoring/Graph/TrackAuthoringGraphDiagnostic.cs b/Quantum.Track/Authoring/Graph/TrackAuthoringGraphDiagnostic.cs
index a72bc50..4146ca3 100644
--- a/Quantum.Track/Authoring/Graph/TrackAuthoringGraphDiagnostic.cs
+++ b/Quantum.Track/Authoring/Graph/TrackAuthoringGraphDiagnostic.cs
@@ -10,7 +10,9 @@ public enum TrackAuthoringGraphDiagnosticCode
MultipleOutgoingEdges = 5,
CycleDetected = 6,
DisconnectedNode = 7,
- AuthoringCompilationFailed = 8
+ AuthoringCompilationFailed = 8,
+ UnsupportedSectionFamily = 9,
+ CandidateOperationFailed = 10
}
///
diff --git a/Quantum.Track/Authoring/Graph/TrackAuthoringGraphNode.cs b/Quantum.Track/Authoring/Graph/TrackAuthoringGraphNode.cs
index 412caf0..6af7351 100644
--- a/Quantum.Track/Authoring/Graph/TrackAuthoringGraphNode.cs
+++ b/Quantum.Track/Authoring/Graph/TrackAuthoringGraphNode.cs
@@ -7,19 +7,29 @@ namespace Quantum.Track.Authoring
///
public sealed class TrackAuthoringGraphNode
{
- public TrackAuthoringGraphNode(GeometricSectionDefinition section)
+ public TrackAuthoringGraphNode(TrackAuthoringSectionDefinition section)
{
Section = section ?? throw new ArgumentNullException(nameof(section));
+ if (string.IsNullOrWhiteSpace(section.TypeId))
+ {
+ throw new ArgumentException(
+ "A graph section definition must provide a stable type discriminator.",
+ nameof(section));
+ }
}
///
- /// Stable graph identity. M157 deliberately reuses the existing section ID.
+ /// Stable graph identity shared by route operations, history, and persistence.
///
public string Id => Section.Id;
///
/// Immutable backend section definition owned by this node snapshot.
///
- public GeometricSectionDefinition Section { get; }
+ public TrackAuthoringSectionDefinition Section { get; }
+
+ public TrackAuthoringSectionFamily Family => Section.Family;
+
+ public string TypeId => Section.TypeId;
}
}
diff --git a/Quantum.Track/Authoring/Graph/TrackAuthoringGraphOperations.cs b/Quantum.Track/Authoring/Graph/TrackAuthoringGraphOperations.cs
new file mode 100644
index 0000000..f19c637
--- /dev/null
+++ b/Quantum.Track/Authoring/Graph/TrackAuthoringGraphOperations.cs
@@ -0,0 +1,231 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace Quantum.Track.Authoring
+{
+ ///
+ /// Pure immutable operations for one deterministic linear authoring route.
+ ///
+ public static class TrackAuthoringGraphOperations
+ {
+ public static TrackAuthoringGraph Append(
+ TrackAuthoringGraph graph,
+ TrackAuthoringSectionDefinition section)
+ {
+ IReadOnlyList orderedNodes = GetOrderedNodes(graph);
+ ValidateNewSection(section, orderedNodes);
+
+ var replacement = new List(orderedNodes)
+ {
+ new TrackAuthoringGraphNode(section)
+ };
+ return Rebuild(graph, replacement);
+ }
+
+ public static TrackAuthoringGraph InsertBefore(
+ TrackAuthoringGraph graph,
+ string anchorNodeId,
+ TrackAuthoringSectionDefinition section)
+ {
+ return Insert(graph, anchorNodeId, section, insertAfter: false);
+ }
+
+ public static TrackAuthoringGraph InsertAfter(
+ TrackAuthoringGraph graph,
+ string anchorNodeId,
+ TrackAuthoringSectionDefinition section)
+ {
+ return Insert(graph, anchorNodeId, section, insertAfter: true);
+ }
+
+ public static TrackAuthoringGraph Delete(
+ TrackAuthoringGraph graph,
+ string nodeId)
+ {
+ IReadOnlyList orderedNodes = GetOrderedNodes(graph);
+ int index = FindNodeIndex(orderedNodes, nodeId, nameof(nodeId));
+ var replacement = new List(orderedNodes);
+ replacement.RemoveAt(index);
+ return Rebuild(graph, replacement);
+ }
+
+ public static TrackAuthoringGraph MoveBefore(
+ TrackAuthoringGraph graph,
+ string nodeId,
+ string anchorNodeId)
+ {
+ return Move(graph, nodeId, anchorNodeId, moveAfter: false);
+ }
+
+ public static TrackAuthoringGraph MoveAfter(
+ TrackAuthoringGraph graph,
+ string nodeId,
+ string anchorNodeId)
+ {
+ return Move(graph, nodeId, anchorNodeId, moveAfter: true);
+ }
+
+ public static TrackAuthoringGraph Replace(
+ TrackAuthoringGraph graph,
+ string nodeId,
+ TrackAuthoringSectionDefinition replacement)
+ {
+ if (graph is null)
+ {
+ throw new ArgumentNullException(nameof(graph));
+ }
+
+ return graph.WithSection(nodeId, replacement);
+ }
+
+ private static TrackAuthoringGraph Insert(
+ TrackAuthoringGraph graph,
+ string anchorNodeId,
+ TrackAuthoringSectionDefinition section,
+ bool insertAfter)
+ {
+ IReadOnlyList orderedNodes = GetOrderedNodes(graph);
+ ValidateNewSection(section, orderedNodes);
+ int anchorIndex = FindNodeIndex(orderedNodes, anchorNodeId, nameof(anchorNodeId));
+
+ var replacement = new List(orderedNodes);
+ replacement.Insert(
+ insertAfter ? anchorIndex + 1 : anchorIndex,
+ new TrackAuthoringGraphNode(section));
+ return Rebuild(graph, replacement);
+ }
+
+ private static TrackAuthoringGraph Move(
+ TrackAuthoringGraph graph,
+ string nodeId,
+ string anchorNodeId,
+ bool moveAfter)
+ {
+ IReadOnlyList orderedNodes = GetOrderedNodes(graph);
+ int movingIndex = FindNodeIndex(orderedNodes, nodeId, nameof(nodeId));
+ FindNodeIndex(orderedNodes, anchorNodeId, nameof(anchorNodeId));
+
+ if (string.Equals(nodeId, anchorNodeId, StringComparison.Ordinal))
+ {
+ return graph;
+ }
+
+ var replacement = new List(orderedNodes);
+ TrackAuthoringGraphNode movingNode = replacement[movingIndex];
+ replacement.RemoveAt(movingIndex);
+ int adjustedAnchorIndex = replacement.FindIndex(node =>
+ string.Equals(node.Id, anchorNodeId, StringComparison.Ordinal));
+ replacement.Insert(
+ moveAfter ? adjustedAnchorIndex + 1 : adjustedAnchorIndex,
+ movingNode);
+
+ return SameOrder(orderedNodes, replacement)
+ ? graph
+ : Rebuild(graph, replacement);
+ }
+
+ private static IReadOnlyList GetOrderedNodes(
+ TrackAuthoringGraph graph)
+ {
+ if (graph is null)
+ {
+ throw new ArgumentNullException(nameof(graph));
+ }
+
+ TrackAuthoringGraphRouteResult route =
+ TrackAuthoringGraphRouteValidator.Validate(graph);
+ if (route.Success)
+ {
+ return route.OrderedNodes;
+ }
+
+ throw new InvalidOperationException(
+ "The source authoring graph is not a deterministic linear route: " +
+ string.Join(
+ " ",
+ route.Diagnostics.Select(diagnostic =>
+ $"{diagnostic.Code}: {diagnostic.Message}")));
+ }
+
+ private static void ValidateNewSection(
+ TrackAuthoringSectionDefinition section,
+ IReadOnlyList orderedNodes)
+ {
+ if (section is null)
+ {
+ throw new ArgumentNullException(nameof(section));
+ }
+
+ if (orderedNodes.Any(node =>
+ string.Equals(node.Id, section.Id, StringComparison.Ordinal)))
+ {
+ throw new ArgumentException(
+ $"Graph node ID '{section.Id}' already exists.",
+ nameof(section));
+ }
+ }
+
+ private static int FindNodeIndex(
+ IReadOnlyList orderedNodes,
+ string nodeId,
+ string parameterName)
+ {
+ if (nodeId is null)
+ {
+ throw new ArgumentNullException(parameterName);
+ }
+
+ for (int i = 0; i < orderedNodes.Count; i++)
+ {
+ if (string.Equals(orderedNodes[i].Id, nodeId, StringComparison.Ordinal))
+ {
+ return i;
+ }
+ }
+
+ throw new ArgumentException(
+ $"Graph node ID '{nodeId}' was not found.",
+ parameterName);
+ }
+
+ private static TrackAuthoringGraph Rebuild(
+ TrackAuthoringGraph source,
+ IReadOnlyList orderedNodes)
+ {
+ var edges = new TrackAuthoringGraphEdge[System.Math.Max(0, orderedNodes.Count - 1)];
+ for (int i = 1; i < orderedNodes.Count; i++)
+ {
+ edges[i - 1] = new TrackAuthoringGraphEdge(
+ orderedNodes[i - 1].Id,
+ orderedNodes[i].Id);
+ }
+
+ return new TrackAuthoringGraph(
+ orderedNodes,
+ edges,
+ source.StartPose,
+ source.Banking);
+ }
+
+ private static bool SameOrder(
+ IReadOnlyList first,
+ IReadOnlyList second)
+ {
+ if (first.Count != second.Count)
+ {
+ return false;
+ }
+
+ for (int i = 0; i < first.Count; i++)
+ {
+ if (!string.Equals(first[i].Id, second[i].Id, StringComparison.Ordinal))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+ }
+}
diff --git a/Quantum.Track/Authoring/Graph/TrackAuthoringGraphRouteResult.cs b/Quantum.Track/Authoring/Graph/TrackAuthoringGraphRouteResult.cs
new file mode 100644
index 0000000..7ccff4a
--- /dev/null
+++ b/Quantum.Track/Authoring/Graph/TrackAuthoringGraphRouteResult.cs
@@ -0,0 +1,30 @@
+using System;
+using System.Collections.Generic;
+
+namespace Quantum.Track.Authoring
+{
+ ///
+ /// Immutable topology-validation result for one deterministic linear route.
+ ///
+ public sealed class TrackAuthoringGraphRouteResult
+ {
+ private readonly IReadOnlyList _orderedNodes;
+ private readonly IReadOnlyList _diagnostics;
+
+ internal TrackAuthoringGraphRouteResult(
+ IEnumerable orderedNodes,
+ IEnumerable diagnostics)
+ {
+ _orderedNodes = new List(
+ orderedNodes ?? throw new ArgumentNullException(nameof(orderedNodes))).AsReadOnly();
+ _diagnostics = new List(
+ diagnostics ?? throw new ArgumentNullException(nameof(diagnostics))).AsReadOnly();
+ }
+
+ public bool Success => _diagnostics.Count == 0;
+
+ public IReadOnlyList OrderedNodes => _orderedNodes;
+
+ public IReadOnlyList Diagnostics => _diagnostics;
+ }
+}
diff --git a/Quantum.Track/Authoring/Graph/TrackAuthoringGraphRouteValidator.cs b/Quantum.Track/Authoring/Graph/TrackAuthoringGraphRouteValidator.cs
new file mode 100644
index 0000000..d4afa07
--- /dev/null
+++ b/Quantum.Track/Authoring/Graph/TrackAuthoringGraphRouteValidator.cs
@@ -0,0 +1,306 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace Quantum.Track.Authoring
+{
+ ///
+ /// Validates and deterministically linearizes graph topology without compiling
+ /// any section payloads.
+ ///
+ ///
+ /// An empty graph is a valid authoring route here. Consumers that require
+ /// compiled geometry, including ,
+ /// apply their own non-empty requirement.
+ ///
+ public static class TrackAuthoringGraphRouteValidator
+ {
+ public static TrackAuthoringGraphRouteResult Validate(TrackAuthoringGraph graph)
+ {
+ if (graph is null)
+ {
+ throw new ArgumentNullException(nameof(graph));
+ }
+
+ if (graph.Nodes.Count == 0)
+ {
+ return graph.Edges.Count == 0
+ ? Success(Array.Empty())
+ : Failure(new[]
+ {
+ new TrackAuthoringGraphDiagnostic(
+ TrackAuthoringGraphDiagnosticCode.UnknownEdgeEndpoint,
+ "An empty authoring graph cannot contain edges.")
+ });
+ }
+
+ var diagnostics = new List();
+ Dictionary? nodesById = BuildNodeIndex(
+ graph.Nodes,
+ diagnostics);
+ if (nodesById is null)
+ {
+ return Failure(diagnostics);
+ }
+
+ var incoming = new Dictionary>(StringComparer.Ordinal);
+ var outgoing = new Dictionary>(StringComparer.Ordinal);
+ for (int i = 0; i < graph.Nodes.Count; i++)
+ {
+ string nodeId = graph.Nodes[i].Id;
+ incoming.Add(nodeId, new List());
+ outgoing.Add(nodeId, new List());
+ }
+
+ AddEdges(graph.Edges, nodesById, incoming, outgoing, diagnostics);
+ if (diagnostics.Count != 0)
+ {
+ return Failure(diagnostics);
+ }
+
+ ValidateDegree(graph.Nodes, incoming, outgoing, diagnostics);
+ if (diagnostics.Count != 0)
+ {
+ return Failure(diagnostics);
+ }
+
+ if (HasCycle(graph.Nodes, incoming, outgoing, out IReadOnlyList cycleNodeIds))
+ {
+ diagnostics.Add(new TrackAuthoringGraphDiagnostic(
+ TrackAuthoringGraphDiagnosticCode.CycleDetected,
+ "The authoring graph contains a cycle involving node IDs: " +
+ string.Join(", ", cycleNodeIds) + "."));
+ return Failure(diagnostics);
+ }
+
+ List orderedNodes = BuildSingleRoute(
+ graph.Nodes,
+ nodesById,
+ incoming,
+ outgoing,
+ diagnostics);
+ return diagnostics.Count == 0
+ ? Success(orderedNodes)
+ : Failure(diagnostics, orderedNodes);
+ }
+
+ private static Dictionary? BuildNodeIndex(
+ IReadOnlyList nodes,
+ ICollection diagnostics)
+ {
+ var result = new Dictionary(StringComparer.Ordinal);
+ for (int i = 0; i < nodes.Count; i++)
+ {
+ TrackAuthoringGraphNode node = nodes[i];
+ if (!result.ContainsKey(node.Id))
+ {
+ result.Add(node.Id, node);
+ continue;
+ }
+
+ diagnostics.Add(new TrackAuthoringGraphDiagnostic(
+ TrackAuthoringGraphDiagnosticCode.DuplicateNodeId,
+ $"Graph node ID '{node.Id}' is duplicated.",
+ nodeId: node.Id));
+ }
+
+ return diagnostics.Count == 0 ? result : null;
+ }
+
+ private static void AddEdges(
+ IReadOnlyList edges,
+ IReadOnlyDictionary nodesById,
+ IDictionary> incoming,
+ IDictionary> outgoing,
+ ICollection diagnostics)
+ {
+ var seenEdges = new HashSet<(string Source, string Target)>();
+ for (int i = 0; i < edges.Count; i++)
+ {
+ TrackAuthoringGraphEdge edge = edges[i];
+ bool sourceExists = nodesById.ContainsKey(edge.SourceNodeId);
+ bool targetExists = nodesById.ContainsKey(edge.TargetNodeId);
+
+ if (!sourceExists || !targetExists)
+ {
+ string missing = !sourceExists && !targetExists
+ ? "source and target node IDs"
+ : !sourceExists ? "source node ID" : "target node ID";
+ diagnostics.Add(new TrackAuthoringGraphDiagnostic(
+ TrackAuthoringGraphDiagnosticCode.UnknownEdgeEndpoint,
+ $"Graph edge '{edge.SourceNodeId}' -> '{edge.TargetNodeId}' has unknown {missing}.",
+ sourceNodeId: edge.SourceNodeId,
+ targetNodeId: edge.TargetNodeId));
+ continue;
+ }
+
+ if (!seenEdges.Add((edge.SourceNodeId, edge.TargetNodeId)))
+ {
+ diagnostics.Add(new TrackAuthoringGraphDiagnostic(
+ TrackAuthoringGraphDiagnosticCode.DuplicateEdge,
+ $"Graph edge '{edge.SourceNodeId}' -> '{edge.TargetNodeId}' is duplicated.",
+ sourceNodeId: edge.SourceNodeId,
+ targetNodeId: edge.TargetNodeId));
+ continue;
+ }
+
+ outgoing[edge.SourceNodeId].Add(edge.TargetNodeId);
+ incoming[edge.TargetNodeId].Add(edge.SourceNodeId);
+ }
+ }
+
+ private static void ValidateDegree(
+ IReadOnlyList nodes,
+ IReadOnlyDictionary> incoming,
+ IReadOnlyDictionary> outgoing,
+ ICollection diagnostics)
+ {
+ for (int i = 0; i < nodes.Count; i++)
+ {
+ string nodeId = nodes[i].Id;
+ if (incoming[nodeId].Count > 1)
+ {
+ diagnostics.Add(new TrackAuthoringGraphDiagnostic(
+ TrackAuthoringGraphDiagnosticCode.MultipleIncomingEdges,
+ $"Graph node ID '{nodeId}' has {incoming[nodeId].Count} incoming edges; merging is not supported.",
+ nodeId: nodeId));
+ }
+
+ if (outgoing[nodeId].Count > 1)
+ {
+ diagnostics.Add(new TrackAuthoringGraphDiagnostic(
+ TrackAuthoringGraphDiagnosticCode.MultipleOutgoingEdges,
+ $"Graph node ID '{nodeId}' has {outgoing[nodeId].Count} outgoing edges; branching is not supported.",
+ nodeId: nodeId));
+ }
+ }
+ }
+
+ private static bool HasCycle(
+ IReadOnlyList nodes,
+ IReadOnlyDictionary> incoming,
+ IReadOnlyDictionary> outgoing,
+ out IReadOnlyList cycleNodeIds)
+ {
+ var remainingIncoming = new Dictionary(StringComparer.Ordinal);
+ var ready = new Queue();
+ for (int i = 0; i < nodes.Count; i++)
+ {
+ string nodeId = nodes[i].Id;
+ int count = incoming[nodeId].Count;
+ remainingIncoming.Add(nodeId, count);
+ if (count == 0)
+ {
+ ready.Enqueue(nodeId);
+ }
+ }
+
+ int visitedCount = 0;
+ while (ready.Count != 0)
+ {
+ string nodeId = ready.Dequeue();
+ visitedCount++;
+ List successors = outgoing[nodeId];
+ for (int i = 0; i < successors.Count; i++)
+ {
+ string successor = successors[i];
+ remainingIncoming[successor]--;
+ if (remainingIncoming[successor] == 0)
+ {
+ ready.Enqueue(successor);
+ }
+ }
+ }
+
+ if (visitedCount == nodes.Count)
+ {
+ cycleNodeIds = Array.Empty();
+ return false;
+ }
+
+ cycleNodeIds = nodes
+ .Where(node => remainingIncoming[node.Id] > 0)
+ .Select(node => node.Id)
+ .ToArray();
+ return true;
+ }
+
+ private static List BuildSingleRoute(
+ IReadOnlyList nodes,
+ IReadOnlyDictionary nodesById,
+ IReadOnlyDictionary> incoming,
+ IReadOnlyDictionary> outgoing,
+ ICollection diagnostics)
+ {
+ string[] startNodeIds = nodes
+ .Where(node => incoming[node.Id].Count == 0)
+ .Select(node => node.Id)
+ .ToArray();
+ string[] endNodeIds = nodes
+ .Where(node => outgoing[node.Id].Count == 0)
+ .Select(node => node.Id)
+ .ToArray();
+
+ if (startNodeIds.Length != 1 || endNodeIds.Length != 1)
+ {
+ diagnostics.Add(new TrackAuthoringGraphDiagnostic(
+ TrackAuthoringGraphDiagnosticCode.DisconnectedNode,
+ "A deterministic linear graph must have exactly one start and one end node; " +
+ $"found starts [{string.Join(", ", startNodeIds)}] and " +
+ $"ends [{string.Join(", ", endNodeIds)}]."));
+ return new List();
+ }
+
+ var orderedNodes = new List(nodes.Count);
+ var visited = new HashSet(StringComparer.Ordinal);
+ string currentNodeId = startNodeIds[0];
+ while (visited.Add(currentNodeId))
+ {
+ orderedNodes.Add(nodesById[currentNodeId]);
+ List successors = outgoing[currentNodeId];
+ if (successors.Count == 0)
+ {
+ break;
+ }
+
+ currentNodeId = successors[0];
+ }
+
+ if (orderedNodes.Count == nodes.Count)
+ {
+ return orderedNodes;
+ }
+
+ for (int i = 0; i < nodes.Count; i++)
+ {
+ TrackAuthoringGraphNode node = nodes[i];
+ if (!visited.Contains(node.Id))
+ {
+ diagnostics.Add(new TrackAuthoringGraphDiagnostic(
+ TrackAuthoringGraphDiagnosticCode.DisconnectedNode,
+ $"Graph node ID '{node.Id}' is disconnected from the single compiled route.",
+ nodeId: node.Id));
+ }
+ }
+
+ return orderedNodes;
+ }
+
+ private static TrackAuthoringGraphRouteResult Success(
+ IEnumerable orderedNodes)
+ {
+ return new TrackAuthoringGraphRouteResult(
+ orderedNodes,
+ Array.Empty());
+ }
+
+ private static TrackAuthoringGraphRouteResult Failure(
+ IEnumerable diagnostics,
+ IEnumerable? orderedNodes = null)
+ {
+ return new TrackAuthoringGraphRouteResult(
+ orderedNodes ?? Array.Empty(),
+ diagnostics);
+ }
+ }
+}
diff --git a/Quantum.Track/Authoring/Graph/TrackAuthoringSectionDefaultFactory.cs b/Quantum.Track/Authoring/Graph/TrackAuthoringSectionDefaultFactory.cs
new file mode 100644
index 0000000..b822574
--- /dev/null
+++ b/Quantum.Track/Authoring/Graph/TrackAuthoringSectionDefaultFactory.cs
@@ -0,0 +1,356 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace Quantum.Track.Authoring
+{
+ ///
+ /// Produces deterministic neighbor-aware defaults for the initial geometric catalog.
+ ///
+ ///
+ /// Roll prefers the immediate upstream section, then the immediate downstream section,
+ /// then zero. Constant curvature prefers a finite-radius non-zero upstream endpoint,
+ /// then downstream, then the positive 25-unit radius fallback. Transitions bridge two
+ /// supported endpoints, copy a sole supported endpoint, or fall back to zero-to-zero.
+ /// Spatial and future non-geometric endpoints are deliberately unavailable rather than
+ /// approximated; the result flags identify that condition and the fallback selected from
+ /// the remaining context. Insertion station is nullable when a preceding timeline node
+ /// does not yet expose a station extent through the current production contracts.
+ ///
+ public static class TrackAuthoringSectionDefaultFactory
+ {
+ public const double DefaultLength = 10.0;
+ public const double PositiveRadiusFallback = 25.0;
+
+ public static TrackAuthoringSectionDefaults ForAppend(
+ TrackAuthoringGraph graph,
+ string sectionTypeId,
+ string sectionId)
+ {
+ IReadOnlyList orderedNodes = GetAuthoringRoute(graph);
+ return Create(orderedNodes, orderedNodes.Count, replacing: false, sectionTypeId, sectionId);
+ }
+
+ public static TrackAuthoringSectionDefaults ForInsertBefore(
+ TrackAuthoringGraph graph,
+ string anchorNodeId,
+ string sectionTypeId,
+ string sectionId)
+ {
+ IReadOnlyList orderedNodes = GetAuthoringRoute(graph);
+ int anchorIndex = FindNodeIndex(orderedNodes, anchorNodeId, nameof(anchorNodeId));
+ return Create(orderedNodes, anchorIndex, replacing: false, sectionTypeId, sectionId);
+ }
+
+ public static TrackAuthoringSectionDefaults ForInsertAfter(
+ TrackAuthoringGraph graph,
+ string anchorNodeId,
+ string sectionTypeId,
+ string sectionId)
+ {
+ IReadOnlyList orderedNodes = GetAuthoringRoute(graph);
+ int anchorIndex = FindNodeIndex(orderedNodes, anchorNodeId, nameof(anchorNodeId));
+ return Create(orderedNodes, anchorIndex + 1, replacing: false, sectionTypeId, sectionId);
+ }
+
+ public static TrackAuthoringSectionDefaults ForReplacement(
+ TrackAuthoringGraph graph,
+ string nodeId,
+ string sectionTypeId)
+ {
+ IReadOnlyList orderedNodes = GetAuthoringRoute(graph);
+ int nodeIndex = FindNodeIndex(orderedNodes, nodeId, nameof(nodeId));
+ return Create(orderedNodes, nodeIndex, replacing: true, sectionTypeId, nodeId);
+ }
+
+ private static TrackAuthoringSectionDefaults Create(
+ IReadOnlyList orderedNodes,
+ int positionIndex,
+ bool replacing,
+ string sectionTypeId,
+ string sectionId)
+ {
+ if (sectionTypeId is null)
+ {
+ throw new ArgumentNullException(nameof(sectionTypeId));
+ }
+
+ AuthoringValidation.RequireId(sectionId);
+
+ TrackAuthoringSectionDefaultFlags flags = TrackAuthoringSectionDefaultFlags.None;
+ double? insertionStation = 0.0;
+ for (int i = 0; i < positionIndex; i++)
+ {
+ if (orderedNodes[i].Section is GeometricSectionDefinition geometry &&
+ insertionStation.HasValue)
+ {
+ double nextStation = insertionStation.Value + geometry.Length;
+ if (AuthoringValidation.IsFinite(nextStation))
+ {
+ insertionStation = nextStation;
+ continue;
+ }
+ }
+
+ insertionStation = null;
+ flags |= TrackAuthoringSectionDefaultFlags.InsertionStationUnavailable;
+ }
+
+ TrackAuthoringSectionDefinition? upstream = positionIndex > 0
+ ? orderedNodes[positionIndex - 1].Section
+ : null;
+ int downstreamIndex = replacing ? positionIndex + 1 : positionIndex;
+ TrackAuthoringSectionDefinition? downstream = downstreamIndex < orderedNodes.Count
+ ? orderedNodes[downstreamIndex].Section
+ : null;
+
+ double inheritedRoll;
+ if (TryGetRoll(upstream, out double upstreamRoll))
+ {
+ inheritedRoll = upstreamRoll;
+ flags |= TrackAuthoringSectionDefaultFlags.RollInheritedFromUpstream;
+ }
+ else if (TryGetRoll(downstream, out double downstreamRoll))
+ {
+ if (upstream != null)
+ {
+ flags |= TrackAuthoringSectionDefaultFlags.UpstreamRollUnavailable;
+ }
+
+ inheritedRoll = downstreamRoll;
+ flags |= TrackAuthoringSectionDefaultFlags.RollInheritedFromDownstream;
+ }
+ else
+ {
+ if (upstream != null)
+ {
+ flags |= TrackAuthoringSectionDefaultFlags.UpstreamRollUnavailable;
+ }
+
+ if (downstream != null)
+ {
+ flags |= TrackAuthoringSectionDefaultFlags.DownstreamRollUnavailable;
+ }
+
+ inheritedRoll = 0.0;
+ flags |= TrackAuthoringSectionDefaultFlags.ZeroRollFallback;
+ }
+
+ double? upstreamEndCurvature = GetEndCurvature(upstream, ref flags);
+ double? downstreamStartCurvature = GetStartCurvature(downstream, ref flags);
+ TrackAuthoringSectionDefinition definition = CreateDefinition(
+ sectionTypeId,
+ sectionId,
+ inheritedRoll,
+ upstreamEndCurvature,
+ downstreamStartCurvature,
+ ref flags);
+
+ return new TrackAuthoringSectionDefaults(
+ definition,
+ insertionStation,
+ upstream?.Id,
+ downstream?.Id,
+ upstreamEndCurvature,
+ downstreamStartCurvature,
+ inheritedRoll,
+ flags);
+ }
+
+ private static TrackAuthoringSectionDefinition CreateDefinition(
+ string sectionTypeId,
+ string sectionId,
+ double rollRadians,
+ double? upstreamEndCurvature,
+ double? downstreamStartCurvature,
+ ref TrackAuthoringSectionDefaultFlags flags)
+ {
+ switch (sectionTypeId)
+ {
+ case TrackAuthoringSectionTypeIds.Straight:
+ return new StraightSectionDefinition(sectionId, DefaultLength, rollRadians);
+
+ case TrackAuthoringSectionTypeIds.ConstantCurvature:
+ if (TryUseFiniteRadius(upstreamEndCurvature, out double upstreamRadius))
+ {
+ flags |= TrackAuthoringSectionDefaultFlags.CurvatureInheritedFromUpstream;
+ return new ConstantCurvatureSectionDefinition(
+ sectionId,
+ DefaultLength,
+ upstreamRadius,
+ rollRadians);
+ }
+
+ if (TryUseFiniteRadius(downstreamStartCurvature, out double downstreamRadius))
+ {
+ flags |= TrackAuthoringSectionDefaultFlags.CurvatureInheritedFromDownstream;
+ return new ConstantCurvatureSectionDefinition(
+ sectionId,
+ DefaultLength,
+ downstreamRadius,
+ rollRadians);
+ }
+
+ flags |= TrackAuthoringSectionDefaultFlags.PositiveRadiusFallback;
+ return new ConstantCurvatureSectionDefinition(
+ sectionId,
+ DefaultLength,
+ PositiveRadiusFallback,
+ rollRadians);
+
+ case TrackAuthoringSectionTypeIds.CurvatureTransition:
+ double startCurvature;
+ double endCurvature;
+ if (upstreamEndCurvature.HasValue && downstreamStartCurvature.HasValue)
+ {
+ startCurvature = upstreamEndCurvature.Value;
+ endCurvature = downstreamStartCurvature.Value;
+ flags |= TrackAuthoringSectionDefaultFlags.TransitionBridgesNeighbors;
+ }
+ else if (upstreamEndCurvature.HasValue)
+ {
+ startCurvature = upstreamEndCurvature.Value;
+ endCurvature = upstreamEndCurvature.Value;
+ flags |= TrackAuthoringSectionDefaultFlags.CurvatureInheritedFromUpstream;
+ }
+ else if (downstreamStartCurvature.HasValue)
+ {
+ startCurvature = downstreamStartCurvature.Value;
+ endCurvature = downstreamStartCurvature.Value;
+ flags |= TrackAuthoringSectionDefaultFlags.CurvatureInheritedFromDownstream;
+ }
+ else
+ {
+ startCurvature = 0.0;
+ endCurvature = 0.0;
+ flags |= TrackAuthoringSectionDefaultFlags.ZeroCurvatureFallback;
+ }
+
+ return new CurvatureTransitionSectionDefinition(
+ sectionId,
+ DefaultLength,
+ startCurvature,
+ endCurvature,
+ CurvatureTransitionInterpolationMode.Linear,
+ rollRadians);
+
+ default:
+ throw new NotSupportedException(
+ $"Section type '{sectionTypeId}' does not have initial authoring defaults.");
+ }
+ }
+
+ private static bool TryUseFiniteRadius(double? curvature, out double radius)
+ {
+ if (!curvature.HasValue || curvature.Value == 0.0)
+ {
+ radius = 0.0;
+ return false;
+ }
+
+ try
+ {
+ radius = TrackAuthoringScalarCurvature.ToSignedRadius(curvature.Value);
+ return true;
+ }
+ catch (ArgumentOutOfRangeException)
+ {
+ radius = 0.0;
+ return false;
+ }
+ }
+
+ private static bool TryGetRoll(
+ TrackAuthoringSectionDefinition? section,
+ out double rollRadians)
+ {
+ if (section is GeometricSectionDefinition geometry)
+ {
+ rollRadians = geometry.RollRadians;
+ return true;
+ }
+
+ rollRadians = 0.0;
+ return false;
+ }
+
+ private static double? GetEndCurvature(
+ TrackAuthoringSectionDefinition? section,
+ ref TrackAuthoringSectionDefaultFlags flags)
+ {
+ if (section is null)
+ {
+ return null;
+ }
+
+ if (TrackAuthoringScalarCurvature.TryGetEndCurvature(section, out double curvature))
+ {
+ return curvature;
+ }
+
+ flags |= TrackAuthoringSectionDefaultFlags.UpstreamScalarCurvatureUnavailable;
+ return null;
+ }
+
+ private static double? GetStartCurvature(
+ TrackAuthoringSectionDefinition? section,
+ ref TrackAuthoringSectionDefaultFlags flags)
+ {
+ if (section is null)
+ {
+ return null;
+ }
+
+ if (TrackAuthoringScalarCurvature.TryGetStartCurvature(section, out double curvature))
+ {
+ return curvature;
+ }
+
+ flags |= TrackAuthoringSectionDefaultFlags.DownstreamScalarCurvatureUnavailable;
+ return null;
+ }
+
+ private static IReadOnlyList GetAuthoringRoute(
+ TrackAuthoringGraph graph)
+ {
+ if (graph is null)
+ {
+ throw new ArgumentNullException(nameof(graph));
+ }
+
+ TrackAuthoringGraphRouteResult route = TrackAuthoringGraphRouteValidator.Validate(graph);
+ if (!route.Success)
+ {
+ throw new InvalidOperationException(
+ "Section defaults require a deterministic linear authoring route: " +
+ string.Join(
+ " ",
+ route.Diagnostics.Select(diagnostic =>
+ $"{diagnostic.Code}: {diagnostic.Message}")));
+ }
+
+ return route.OrderedNodes;
+ }
+
+ private static int FindNodeIndex(
+ IReadOnlyList orderedNodes,
+ string nodeId,
+ string parameterName)
+ {
+ if (nodeId is null)
+ {
+ throw new ArgumentNullException(parameterName);
+ }
+
+ for (int i = 0; i < orderedNodes.Count; i++)
+ {
+ if (string.Equals(orderedNodes[i].Id, nodeId, StringComparison.Ordinal))
+ {
+ return i;
+ }
+ }
+
+ throw new ArgumentException($"Graph node ID '{nodeId}' was not found.", parameterName);
+ }
+ }
+}
diff --git a/Quantum.Track/Authoring/SpatialSectionDefinition.cs b/Quantum.Track/Authoring/SpatialSectionDefinition.cs
index b0bd165..aff4551 100644
--- a/Quantum.Track/Authoring/SpatialSectionDefinition.cs
+++ b/Quantum.Track/Authoring/SpatialSectionDefinition.cs
@@ -71,6 +71,8 @@ public SpatialSectionDefinition(
///
public int Degree { get; }
+ public override string TypeId => TrackAuthoringSectionTypeIds.Spatial;
+
private static void ValidateControlPoints(IReadOnlyList controlPoints)
{
for (int i = 0; i < controlPoints.Count; i++)
diff --git a/Quantum.Track/Authoring/StraightSectionDefinition.cs b/Quantum.Track/Authoring/StraightSectionDefinition.cs
index 19cf5a8..4eb868e 100644
--- a/Quantum.Track/Authoring/StraightSectionDefinition.cs
+++ b/Quantum.Track/Authoring/StraightSectionDefinition.cs
@@ -12,5 +12,7 @@ public StraightSectionDefinition(
: base(id, length, rollRadians)
{
}
+
+ public override string TypeId => TrackAuthoringSectionTypeIds.Straight;
}
}
diff --git a/Quantum.Track/Authoring/TrackAuthoringBoundaryContinuityDiagnostics.cs b/Quantum.Track/Authoring/TrackAuthoringBoundaryContinuityDiagnostics.cs
index 7511bae..983b82b 100644
--- a/Quantum.Track/Authoring/TrackAuthoringBoundaryContinuityDiagnostics.cs
+++ b/Quantum.Track/Authoring/TrackAuthoringBoundaryContinuityDiagnostics.cs
@@ -276,40 +276,28 @@ public static TrackAuthoringBoundaryContinuityReport Analyze(
private static double GetStartCurvature(GeometricSectionDefinition section)
{
- switch (section)
+ if (TrackAuthoringScalarCurvature.TryGetStartCurvature(
+ section,
+ out double curvature))
{
- case StraightSectionDefinition _:
- return 0.0;
-
- case ConstantCurvatureSectionDefinition constantCurvature:
- return 1.0 / constantCurvature.Radius;
-
- case CurvatureTransitionSectionDefinition transition:
- return transition.StartCurvature;
-
- default:
- throw new NotSupportedException(
- $"Unsupported authoring section type '{section.GetType().FullName}'.");
+ return curvature;
}
+
+ throw new NotSupportedException(
+ $"Authoring section type '{section.GetType().FullName}' does not expose supported scalar curvature.");
}
private static double GetEndCurvature(GeometricSectionDefinition section)
{
- switch (section)
+ if (TrackAuthoringScalarCurvature.TryGetEndCurvature(
+ section,
+ out double curvature))
{
- case StraightSectionDefinition _:
- return 0.0;
-
- case ConstantCurvatureSectionDefinition constantCurvature:
- return 1.0 / constantCurvature.Radius;
-
- case CurvatureTransitionSectionDefinition transition:
- return transition.EndCurvature;
-
- default:
- throw new NotSupportedException(
- $"Unsupported authoring section type '{section.GetType().FullName}'.");
+ return curvature;
}
+
+ throw new NotSupportedException(
+ $"Authoring section type '{section.GetType().FullName}' does not expose supported scalar curvature.");
}
private static double GetShortestFullTurnDelta(double deltaRadians)
diff --git a/Quantum.Track/Authoring/TrackAuthoringDocumentBuilder.cs b/Quantum.Track/Authoring/TrackAuthoringDocumentBuilder.cs
index 8d0d1cb..d5e46e9 100644
--- a/Quantum.Track/Authoring/TrackAuthoringDocumentBuilder.cs
+++ b/Quantum.Track/Authoring/TrackAuthoringDocumentBuilder.cs
@@ -202,7 +202,7 @@ private static GeometricSection CreateGeometricSection(
{
return new GeometricSection(
definition.Length,
- curvature: 1.0 / arc.Radius,
+ curvature: TrackAuthoringScalarCurvature.FromSignedRadius(arc.Radius),
roll: definition.RollRadians);
}
diff --git a/Quantum.Track/Authoring/TrackAuthoringScalarCurvature.cs b/Quantum.Track/Authoring/TrackAuthoringScalarCurvature.cs
new file mode 100644
index 0000000..e45f227
--- /dev/null
+++ b/Quantum.Track/Authoring/TrackAuthoringScalarCurvature.cs
@@ -0,0 +1,193 @@
+using System;
+
+namespace Quantum.Track.Authoring
+{
+ ///
+ /// Direction of signed scalar curvature in the established authoring frame.
+ ///
+ public enum TrackAuthoringCurvatureDirection
+ {
+ Negative = -1,
+ Straight = 0,
+ Positive = 1
+ }
+
+ ///
+ /// Central signed scalar-curvature conventions for supported planar authoring sections.
+ ///
+ ///
+ /// Positive signed radius and curvature preserve the existing convention of turning
+ /// toward positive Y from a positive-X heading. Spatial sections intentionally do not
+ /// expose an approximated scalar curvature through this API.
+ ///
+ public static class TrackAuthoringScalarCurvature
+ {
+ public static double FromSignedRadius(double signedRadius)
+ {
+ if (!AuthoringValidation.IsFinite(signedRadius) || signedRadius == 0.0)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(signedRadius),
+ signedRadius,
+ "Signed radius must be finite and non-zero.");
+ }
+
+ double curvature = 1.0 / signedRadius;
+ if (!AuthoringValidation.IsFinite(curvature))
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(signedRadius),
+ signedRadius,
+ "Signed radius must produce finite signed curvature.");
+ }
+
+ return curvature;
+ }
+
+ public static double ToSignedRadius(double signedCurvature)
+ {
+ if (!AuthoringValidation.IsFinite(signedCurvature) || signedCurvature == 0.0)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(signedCurvature),
+ signedCurvature,
+ "Signed curvature must be finite and non-zero to have a finite radius.");
+ }
+
+ double radius = 1.0 / signedCurvature;
+ if (!AuthoringValidation.IsFinite(radius))
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(signedCurvature),
+ signedCurvature,
+ "Signed curvature must produce a finite signed radius.");
+ }
+
+ return radius;
+ }
+
+ public static TrackAuthoringCurvatureDirection DirectionOf(double signedCurvature)
+ {
+ AuthoringValidation.RequireFinite(
+ signedCurvature,
+ nameof(signedCurvature),
+ "Signed curvature");
+
+ if (signedCurvature > 0.0)
+ {
+ return TrackAuthoringCurvatureDirection.Positive;
+ }
+
+ if (signedCurvature < 0.0)
+ {
+ return TrackAuthoringCurvatureDirection.Negative;
+ }
+
+ return TrackAuthoringCurvatureDirection.Straight;
+ }
+
+ public static double FromMagnitudeAndDirection(
+ double magnitude,
+ TrackAuthoringCurvatureDirection direction)
+ {
+ if (!AuthoringValidation.IsFinite(magnitude) || magnitude < 0.0)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(magnitude),
+ magnitude,
+ "Curvature magnitude must be finite and non-negative.");
+ }
+
+ if (direction != TrackAuthoringCurvatureDirection.Negative &&
+ direction != TrackAuthoringCurvatureDirection.Straight &&
+ direction != TrackAuthoringCurvatureDirection.Positive)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(direction),
+ direction,
+ "Unsupported scalar-curvature direction.");
+ }
+
+ if (direction == TrackAuthoringCurvatureDirection.Straight)
+ {
+ if (magnitude != 0.0)
+ {
+ throw new ArgumentException(
+ "Straight curvature direction requires zero magnitude.",
+ nameof(magnitude));
+ }
+
+ return 0.0;
+ }
+
+ if (magnitude == 0.0)
+ {
+ throw new ArgumentException(
+ "Positive or negative curvature direction requires non-zero magnitude.",
+ nameof(magnitude));
+ }
+
+ return direction == TrackAuthoringCurvatureDirection.Positive
+ ? magnitude
+ : -magnitude;
+ }
+
+ public static bool TryGetStartCurvature(
+ TrackAuthoringSectionDefinition section,
+ out double signedCurvature)
+ {
+ if (section is null)
+ {
+ throw new ArgumentNullException(nameof(section));
+ }
+
+ switch (section)
+ {
+ case StraightSectionDefinition _:
+ signedCurvature = 0.0;
+ return true;
+
+ case ConstantCurvatureSectionDefinition constantCurvature:
+ signedCurvature = FromSignedRadius(constantCurvature.Radius);
+ return true;
+
+ case CurvatureTransitionSectionDefinition transition:
+ signedCurvature = transition.StartCurvature;
+ return true;
+
+ default:
+ signedCurvature = 0.0;
+ return false;
+ }
+ }
+
+ public static bool TryGetEndCurvature(
+ TrackAuthoringSectionDefinition section,
+ out double signedCurvature)
+ {
+ if (section is null)
+ {
+ throw new ArgumentNullException(nameof(section));
+ }
+
+ switch (section)
+ {
+ case StraightSectionDefinition _:
+ signedCurvature = 0.0;
+ return true;
+
+ case ConstantCurvatureSectionDefinition constantCurvature:
+ signedCurvature = FromSignedRadius(constantCurvature.Radius);
+ return true;
+
+ case CurvatureTransitionSectionDefinition transition:
+ signedCurvature = transition.EndCurvature;
+ return true;
+
+ default:
+ signedCurvature = 0.0;
+ return false;
+ }
+ }
+ }
+}
diff --git a/Quantum.Track/Authoring/TrackAuthoringSectionCatalog.cs b/Quantum.Track/Authoring/TrackAuthoringSectionCatalog.cs
new file mode 100644
index 0000000..5ffcf0f
--- /dev/null
+++ b/Quantum.Track/Authoring/TrackAuthoringSectionCatalog.cs
@@ -0,0 +1,29 @@
+using System;
+using System.Collections.Generic;
+
+namespace Quantum.Track.Authoring
+{
+ ///
+ /// Built-in backend section types that currently have authoring definitions.
+ ///
+ public static class TrackAuthoringSectionCatalog
+ {
+ private static readonly IReadOnlyList TypesValue =
+ Array.AsReadOnly(new[]
+ {
+ Geometry(TrackAuthoringSectionTypeIds.Straight),
+ Geometry(TrackAuthoringSectionTypeIds.ConstantCurvature),
+ Geometry(TrackAuthoringSectionTypeIds.CurvatureTransition),
+ Geometry(TrackAuthoringSectionTypeIds.Spatial)
+ });
+
+ public static IReadOnlyList Types => TypesValue;
+
+ private static TrackAuthoringSectionTypeDescriptor Geometry(string typeId)
+ {
+ return new TrackAuthoringSectionTypeDescriptor(
+ typeId,
+ TrackAuthoringSectionFamily.Geometry);
+ }
+ }
+}
diff --git a/Quantum.Track/Authoring/TrackAuthoringSectionDefaults.cs b/Quantum.Track/Authoring/TrackAuthoringSectionDefaults.cs
new file mode 100644
index 0000000..59bf92c
--- /dev/null
+++ b/Quantum.Track/Authoring/TrackAuthoringSectionDefaults.cs
@@ -0,0 +1,70 @@
+using System;
+
+namespace Quantum.Track.Authoring
+{
+ [Flags]
+ public enum TrackAuthoringSectionDefaultFlags
+ {
+ None = 0,
+ RollInheritedFromUpstream = 1 << 0,
+ RollInheritedFromDownstream = 1 << 1,
+ ZeroRollFallback = 1 << 2,
+ CurvatureInheritedFromUpstream = 1 << 3,
+ CurvatureInheritedFromDownstream = 1 << 4,
+ TransitionBridgesNeighbors = 1 << 5,
+ PositiveRadiusFallback = 1 << 6,
+ ZeroCurvatureFallback = 1 << 7,
+ UpstreamScalarCurvatureUnavailable = 1 << 8,
+ DownstreamScalarCurvatureUnavailable = 1 << 9,
+ InsertionStationUnavailable = 1 << 10,
+ UpstreamRollUnavailable = 1 << 11,
+ DownstreamRollUnavailable = 1 << 12
+ }
+
+ ///
+ /// Deterministic production section defaults and the route context that produced them.
+ ///
+ public sealed class TrackAuthoringSectionDefaults
+ {
+ internal TrackAuthoringSectionDefaults(
+ TrackAuthoringSectionDefinition definition,
+ double? insertionStation,
+ string? upstreamNodeId,
+ string? downstreamNodeId,
+ double? upstreamEndCurvature,
+ double? downstreamStartCurvature,
+ double inheritedRollRadians,
+ TrackAuthoringSectionDefaultFlags flags)
+ {
+ Definition = definition ?? throw new ArgumentNullException(nameof(definition));
+ InsertionStation = insertionStation;
+ UpstreamNodeId = upstreamNodeId;
+ DownstreamNodeId = downstreamNodeId;
+ UpstreamEndCurvature = upstreamEndCurvature;
+ DownstreamStartCurvature = downstreamStartCurvature;
+ InheritedRollRadians = inheritedRollRadians;
+ Flags = flags;
+ }
+
+ public TrackAuthoringSectionDefinition Definition { get; }
+
+ ///
+ /// Start station of the insertion or replacement position in station-distance units.
+ ///
+ public double? InsertionStation { get; }
+
+ public bool HasInsertionStation => InsertionStation.HasValue;
+
+ public string? UpstreamNodeId { get; }
+
+ public string? DownstreamNodeId { get; }
+
+ public double? UpstreamEndCurvature { get; }
+
+ public double? DownstreamStartCurvature { get; }
+
+ public double InheritedRollRadians { get; }
+
+ public TrackAuthoringSectionDefaultFlags Flags { get; }
+ }
+}
diff --git a/Quantum.Track/Authoring/TrackAuthoringSectionDefinition.cs b/Quantum.Track/Authoring/TrackAuthoringSectionDefinition.cs
new file mode 100644
index 0000000..4d7262e
--- /dev/null
+++ b/Quantum.Track/Authoring/TrackAuthoringSectionDefinition.cs
@@ -0,0 +1,42 @@
+using System;
+
+namespace Quantum.Track.Authoring
+{
+ ///
+ /// Immutable typed definition owned by one route-authoring graph node.
+ ///
+ public abstract class TrackAuthoringSectionDefinition
+ {
+ protected TrackAuthoringSectionDefinition(
+ string id,
+ TrackAuthoringSectionFamily family)
+ {
+ if (family != TrackAuthoringSectionFamily.Geometry &&
+ family != TrackAuthoringSectionFamily.Force)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(family),
+ family,
+ "Unsupported track authoring section family.");
+ }
+
+ Id = AuthoringValidation.RequireId(id);
+ Family = family;
+ }
+
+ ///
+ /// Stable route-node identity preserved across parameter edits and reordering.
+ ///
+ public string Id { get; }
+
+ ///
+ /// High-level section family used for compiler and editor catalog dispatch.
+ ///
+ public TrackAuthoringSectionFamily Family { get; }
+
+ ///
+ /// Stable backend section-type discriminator.
+ ///
+ public abstract string TypeId { get; }
+ }
+}
diff --git a/Quantum.Track/Authoring/TrackAuthoringSectionFamily.cs b/Quantum.Track/Authoring/TrackAuthoringSectionFamily.cs
new file mode 100644
index 0000000..6d6f472
--- /dev/null
+++ b/Quantum.Track/Authoring/TrackAuthoringSectionFamily.cs
@@ -0,0 +1,11 @@
+namespace Quantum.Track.Authoring
+{
+ ///
+ /// High-level authoring family for a route section definition.
+ ///
+ public enum TrackAuthoringSectionFamily
+ {
+ Geometry = 0,
+ Force = 1
+ }
+}
diff --git a/Quantum.Track/Authoring/TrackAuthoringSectionTypeDescriptor.cs b/Quantum.Track/Authoring/TrackAuthoringSectionTypeDescriptor.cs
new file mode 100644
index 0000000..c3f180d
--- /dev/null
+++ b/Quantum.Track/Authoring/TrackAuthoringSectionTypeDescriptor.cs
@@ -0,0 +1,35 @@
+using System;
+
+namespace Quantum.Track.Authoring
+{
+ ///
+ /// Engine-agnostic identity for one section type available to authoring tools.
+ ///
+ public sealed class TrackAuthoringSectionTypeDescriptor
+ {
+ public TrackAuthoringSectionTypeDescriptor(
+ string typeId,
+ TrackAuthoringSectionFamily family)
+ {
+ TypeId = string.IsNullOrWhiteSpace(typeId)
+ ? throw new ArgumentException(
+ "A section type discriminator is required.",
+ nameof(typeId))
+ : typeId;
+ if (family != TrackAuthoringSectionFamily.Geometry &&
+ family != TrackAuthoringSectionFamily.Force)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(family),
+ family,
+ "Unsupported track authoring section family.");
+ }
+
+ Family = family;
+ }
+
+ public string TypeId { get; }
+
+ public TrackAuthoringSectionFamily Family { get; }
+ }
+}
diff --git a/Quantum.Track/Authoring/TrackAuthoringSectionTypeIds.cs b/Quantum.Track/Authoring/TrackAuthoringSectionTypeIds.cs
new file mode 100644
index 0000000..97ae591
--- /dev/null
+++ b/Quantum.Track/Authoring/TrackAuthoringSectionTypeIds.cs
@@ -0,0 +1,20 @@
+namespace Quantum.Track.Authoring
+{
+ ///
+ /// Stable backend discriminators for built-in route section definitions.
+ ///
+ ///
+ /// These values are backend identities, not UI labels or serialized package
+ /// vocabulary. Versioned IO adapters map them to their own contract values.
+ ///
+ public static class TrackAuthoringSectionTypeIds
+ {
+ public const string Straight = "geometry.straight";
+
+ public const string ConstantCurvature = "geometry.constantCurvature";
+
+ public const string CurvatureTransition = "geometry.curvatureTransition";
+
+ public const string Spatial = "geometry.spatial";
+ }
+}
diff --git a/docs/editor/m166-first-functional-track-authoring.md b/docs/editor/m166-first-functional-track-authoring.md
new file mode 100644
index 0000000..9978936
--- /dev/null
+++ b/docs/editor/m166-first-functional-track-authoring.md
@@ -0,0 +1,86 @@
+# Milestone 166 First Functional Track Authoring
+
+## Scope
+
+M166 establishes the first end-to-end track creation workflow:
+
+1. New creates an empty authoring graph.
+2. Add creates a typed Straight, Constant Curvature, or Curvature Transition section.
+3. Before and After insert relative to the selected stable node ID.
+4. Delete and Move Up/Down use immutable backend route operations.
+5. Applying an edit validates and compiles the complete candidate route.
+6. Only a successful candidate replaces the active graph and compilation.
+7. Undo/Redo restore graph snapshots, including the empty route.
+8. A non-empty compiled route saves and reopens through Track Layout Package V2.
+
+Spatial NURBS sections remain supported by Open/Save and compilation, but M166 does
+not add spatial control-point creation or editing.
+
+## Backend authoring contract
+
+TrackAuthoringSectionDefinition is the common immutable node payload. It owns:
+
+- stable section ID;
+- TrackAuthoringSectionFamily; and
+- stable backend TypeId.
+
+The geometric definitions derive from this contract and remain their own typed
+parameter and validation models. The backend discriminator is independent from
+Avalonia labels and Track Layout Package vocabulary.
+
+TrackAuthoringGraphRouteValidator validates and orders topology without compiling
+payloads. An empty route is structurally valid. TrackAuthoringGraphCompiler adds
+the non-empty and compiler-support requirements.
+
+TrackAuthoringGraphOperations owns append, insert, delete, move, and replace
+behavior. Avalonia does not construct graph edges.
+
+## Atomic editor transaction
+
+ Avalonia field draft
+ -> typed Quantum.Track definition
+ -> immutable candidate graph
+ -> topology validation
+ -> complete backend compilation
+ -> TrackGraphSnapshotOperation
+ -> viewport and Math Plot refresh
+
+Invalid definitions, invalid topology, unsupported families, and downstream compile
+failures do not replace the active graph, compilation, viewport, dirty state, or
+Undo history.
+
+The empty graph has no compilation, viewport samples, or V2 package. Save is enabled
+after the first section compiles. Deleting the final section returns to the empty
+state and Undo restores the prior compiled graph.
+
+## Banking policy
+
+M166 does not silently remap explicit station-domain banking keys. A section insert,
+delete, or length edit that invalidates explicit banking coverage is rejected by the
+existing backend compilation gate. Automatic banking rebasing requires a separately
+designed authoring policy.
+
+## Persistence
+
+Track Layout Package V2 continues to preserve the four existing geometric section
+types. M166 does not add force data to V2 and does not save empty packages.
+
+Future force-authored sections require:
+
+- an immutable force authoring definition;
+- explicit force-to-centerline compiler semantics;
+- structured diagnostics; and
+- a lossless source-authoring project or companion package.
+
+The common graph identity, topology, selection, and Undo infrastructure does not
+depend on those future compiler or persistence decisions.
+
+## Explicit exclusions
+
+- force-authored nodes and FVD solver integration;
+- spatial control-point editing;
+- imported-curve and predefined-element implementations;
+- automatic banking-key remapping;
+- branching routes and graph-layout persistence;
+- force profiles added to Track Layout Package V2; and
+- Unity, Unreal, or renderer dependencies in backend projects.