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 @@ + + +