Table of Contents

Vec3dControl Component

Vec3dControl edits and displays a three-component vector. It is an embedded widget with no route and no Control-Tree node of its own, reached only through the editors that host it.

Key Model

The persisted model is Vec3d, exposed over /api/Vec3d by Widget/Vec3dController.cs.

In the web application the widget's own model is the plain { x, y, z } number triple exported as interface Vec3 from wwwroot-src/src/components/widgets/Vec3Input.vue. The widget carries no key and calls no endpoint: the host binds it with v-model plus an @update:model-value handler, and that handler does the persisting, each host on its own terms. The four transformer editors call updateVec3d from wwwroot-src/src/api/geometry.ts, which posts all three axes together to /api/Vec3d/Update, while wwwroot-src/src/components/geom/Box3dEditor.vue folds the edited triple back into a min and max pair and posts that to /api/Box3d/Update.

The WPF control likewise holds no value of its own. GetterFunc returns the live Vec3d instance, the text handlers write the parsed numbers straight onto it, and UpdateByContentFunc is awaited afterwards so the host can push the change on.

The web props are modelValue, disable, readonly, normalize, labels and textMode, and the emits are update:modelValue and normalize. labels is a three-tuple defaulting to ['X', 'Y', 'Z'], and carries units where the host has them, as in ['X (mm)', 'Y (mm)', 'Z (mm)']. textMode sets the initial mode only and defaults to false. The corresponding WPF properties are ShowNormalizeButton, IsTextMode and IsReadOnly.

Layout

  • Vec3dControl
    • Mode Toggle Button — always present; switches between the per-axis columns and the single-field text form, and is highlighted while the text form is showing
    • X, Y and Z Input Fields — the per-axis form, three fields side by side
    • Vector Text Field — the single-field form, standing in place of the three axis fields
    • Normalize Button — present only when the normalize flag is set, and disabled while the control is disabled or readonly

In the WPF client the two forms are two panels, StandardModePanel and TextModePanel, of which exactly one is visible; each panel carries its own mode-toggle and normalize buttons.

Feature

Per-axis form

X, Y and Z in three separate fields. The web widget renders every finite value at full precision; the WPF control renders each axis with a fixed three decimal places.

Single-field text form

The web form is (x, y, z). Its parser strips surrounding brackets and splits on comma, semicolon or whitespace, requiring at least three parts, and entering the text form re-syncs the field from the current value so a stale in-progress edit is discarded. The WPF text box holds comma-separated x,y,z without brackets and accepts two further shapes: a pasted 4x4 matrix, from which it takes the translation column, and loose text, from which it takes the first three numbers it can read.

Vector normalization

The normalize button is opt-in and off by default in both clients: the normalize prop in the web widget, the ShowNormalizeButton property in the WPF control.

In the web widget the button only raises a normalize event, and the embedding editor performs the normalization on its own owning object. wwwroot-src/src/components/topo/StaticRotationEditor.vue posts to /api/StaticRotation/NormalizeAxis, while wwwroot-src/src/components/topo/DynamicRotationEditor.vue and wwwroot-src/src/components/topo/DynamicTranslationEditor.vue call normalizeDynamicRotationAxis and normalizeDynamicTranslationAxis from wwwroot-src/src/api/transformer.ts. The WPF control normalizes the vector in place and writes the result back at three decimal places.

Commit and special values

Web edits commit on blur or Enter, never per keystroke, and a value is emitted only when it differs from the current model. An empty field parses to 0; any other unparseable text reverts the field to the last valid value. Formatting and parsing are local to the component: NaN renders as NaN and the infinities as Infinity and -Infinity, and the parser accepts those spellings case-insensitively as well as the ∞ and -∞ glyphs. The WPF control commits on every keystroke instead, leaving the model untouched while the text is unparseable so that a half-typed number is not treated as an error.

Refer Sample Code

<UserControl x:Class="HiNC_2025_win_desktop.Geom.Vec3dControl"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008" 
             mc:Ignorable="d" 
             d:DesignHeight="30" d:DesignWidth="200">
    <Grid>
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="*"/>
            <ColumnDefinition Width="*"/>
            <ColumnDefinition Width="*"/>
            <ColumnDefinition Width="Auto"/>
            <ColumnDefinition Width="Auto"/>
        </Grid.ColumnDefinitions>

        <!-- Standard Mode Panel -->
        <StackPanel x:Name="StandardModePanel" Orientation="Horizontal" Grid.ColumnSpan="5">
            <TextBox x:Name="XTextBox" Width="60" Margin="0,0,2,0" TextChanged="XTextBox_TextChanged" IsReadOnly="{Binding IsReadOnly}"/>
            <TextBox x:Name="YTextBox" Width="60" Margin="0,0,2,0" TextChanged="YTextBox_TextChanged" IsReadOnly="{Binding IsReadOnly}"/>
            <TextBox x:Name="ZTextBox" Width="60" Margin="0,0,2,0" TextChanged="ZTextBox_TextChanged" IsReadOnly="{Binding IsReadOnly}"/>
            <Button Content="{DynamicResource Vec3d_TextMode_Toggle}" Width="20" Click="TextModeToggle_Click" Margin="0,0,2,0" ToolTip="{DynamicResource Vec3d_TextMode_Tooltip}"/>
            <Button Content="{DynamicResource Vec3d_Normalize}" Width="20" Click="NormalizeButton_Click" Visibility="{Binding ShowNormalizeButton, Converter={StaticResource BooleanToVisibilityConverter}}" ToolTip="{DynamicResource Vec3d_Normalize_Tooltip}"/>
        </StackPanel>

        <!-- Text Mode Panel -->
        <StackPanel x:Name="TextModePanel" Orientation="Horizontal" Grid.ColumnSpan="5" Visibility="Collapsed">
            <TextBox x:Name="VectorTextBox" Width="180" Margin="0,0,2,0" TextChanged="VectorTextBox_TextChanged" IsReadOnly="{Binding IsReadOnly}"
                     ToolTip="{DynamicResource Vec3d_TextMode_Format_Tooltip}"/>
            <Button Content="{DynamicResource Vec3d_StandardMode_Toggle}" Width="20" Click="StandardModeToggle_Click" Margin="0,0,2,0" ToolTip="{DynamicResource Vec3d_StandardMode_Tooltip}"/>
            <Button Content="{DynamicResource Vec3d_Normalize}" Width="20" Click="NormalizeButton_Click" Visibility="{Binding ShowNormalizeButton, Converter={StaticResource BooleanToVisibilityConverter}}" ToolTip="{DynamicResource Vec3d_Normalize_Tooltip}"/>
        </StackPanel>
    </Grid>
</UserControl> 
using System;
using System.Threading.Tasks;
using System.Windows.Controls;
using System.Windows;
using Hi.Geom;
using System.Text.RegularExpressions;
using System.ComponentModel;
using Hi.Common;
using Hi.Common.Messages;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;

namespace HiNC_2025_win_desktop.Geom
{
    /// <summary>
    /// Vec3dControl.xaml 的交互逻辑
    /// </summary>
    public partial class Vec3dControl : UserControl, INotifyPropertyChanged
    {
        private bool _isUpdating = false;
        private Func<Vec3d> _getterFunc;
        private Func<Task> _updateByContentFunc;
        private bool _showNormalizeButton = false;
        private bool _isTextMode = false;
        private bool _isReadOnly = false;
        private readonly ILogger<Vec3dControl> _logger = App.ServiceProvider.GetRequiredService<ILogger<Vec3dControl>>();

        private static readonly Regex VectorRegex = new Regex(@"^\s*[\(\[\{]?\s*(-?\d*\.?\d+)\s*,\s*(-?\d*\.?\d+)\s*,\s*(-?\d*\.?\d+)\s*[\)\]\}]?\s*$", RegexOptions.Compiled);
        private static readonly Regex MatrixRegex = new Regex(@"\{(?:\s*\{?\s*(-?\d*\.?\d+)\s*,\s*(-?\d*\.?\d+)\s*,\s*(-?\d*\.?\d+)\s*,\s*(-?\d*\.?\d+)\s*\}?\s*,){3}\s*\{?\s*(-?\d*\.?\d+)\s*,\s*(-?\d*\.?\d+)\s*,\s*(-?\d*\.?\d+)\s*,\s*(-?\d*\.?\d+)\s*\}?\s*\}", RegexOptions.Compiled);

        public event PropertyChangedEventHandler PropertyChanged;

        public string ControlId { get; set; } = Guid.NewGuid().ToString();

        public Func<Vec3d> GetterFunc
        {
            get => _getterFunc;
            set
            {
                _getterFunc = value;
                UpdateUI();
            }
        }

        public Func<Task> UpdateByContentFunc
        {
            get => _updateByContentFunc;
            set => _updateByContentFunc = value;
        }

        public bool ShowNormalizeButton
        {
            get => _showNormalizeButton;
            set
            {
                if (_showNormalizeButton != value)
                {
                    _showNormalizeButton = value;
                    OnPropertyChanged(nameof(ShowNormalizeButton));
                }
            }
        }

        public bool IsTextMode
        {
            get => _isTextMode;
            set
            {
                if (_isTextMode != value)
                {
                    _isTextMode = value;
                    UpdateModeVisibility();
                    OnPropertyChanged(nameof(IsTextMode));
                }
            }
        }

        public bool IsReadOnly
        {
            get => _isReadOnly;
            set
            {
                if (_isReadOnly != value)
                {
                    _isReadOnly = value;
                    OnPropertyChanged(nameof(IsReadOnly));
                }
            }
        }

        protected void OnPropertyChanged(string propertyName)
        {
            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
        }

        public Vec3dControl()
        {
            InitializeComponent();
            DataContext = this;
        }

        private void UpdateModeVisibility()
        {
            if (IsTextMode)
            {
                StandardModePanel.Visibility = Visibility.Collapsed;
                TextModePanel.Visibility = Visibility.Visible;
                UpdateVectorTextFromXYZ();
            }
            else
            {
                StandardModePanel.Visibility = Visibility.Visible;
                TextModePanel.Visibility = Visibility.Collapsed;
            }
        }

        private void UpdateVectorTextFromXYZ()
        {
            if (_isUpdating) return;

            if (double.TryParse(XTextBox.Text, out double x) &&
                double.TryParse(YTextBox.Text, out double y) &&
                double.TryParse(ZTextBox.Text, out double z))
            {
                VectorTextBox.Text = $"{x},{y},{z}";
            }
        }

        private void TextModeToggle_Click(object sender, RoutedEventArgs e)
        {
            IsTextMode = true;
        }

        private void StandardModeToggle_Click(object sender, RoutedEventArgs e)
        {
            IsTextMode = false;
        }

        private async void NormalizeButton_Click(object sender, RoutedEventArgs e)
        {
            if (_isUpdating || _getterFunc == null ||  IsReadOnly)
                return;

            try
            {
                _isUpdating = true;

                var vec = _getterFunc();
                if (vec != null)
                {
                    vec.Normalize();
                    
                    XTextBox.Text = vec.X.ToString("F3");
                    YTextBox.Text = vec.Y.ToString("F3");
                    ZTextBox.Text = vec.Z.ToString("F3");
                    
                    if (IsTextMode)
                    {
                        VectorTextBox.Text = $"{vec.X:F3},{vec.Y:F3},{vec.Z:F3}";
                    }
                    if(_updateByContentFunc != null)
                        await _updateByContentFunc();
                }
            }
            catch (Exception ex)
            {
                _logger.LogWarning(string.Format(Application.Current.FindResource("Vec3d_Update_Error").ToString(), ex.Message));
                _logger.LogError(ex, "{Message}", ex.Message);
            }
            finally
            {
                _isUpdating = false;
            }
        }

        public void UpdateUI()
        {
            if (_isUpdating || _getterFunc == null)
                return;

            try
            {
                _isUpdating = true;
                var vec = _getterFunc();
                if (vec != null)
                {
                    XTextBox.Text = vec.X.ToString("F3");
                    YTextBox.Text = vec.Y.ToString("F3");
                    ZTextBox.Text = vec.Z.ToString("F3");

                    if (IsTextMode)
                    {
                        VectorTextBox.Text = $"{vec.X},{vec.Y},{vec.Z}";
                    }
                }
                else
                {
                    XTextBox.Text = "";
                    YTextBox.Text = "";
                    ZTextBox.Text = "";
                    VectorTextBox.Text = "";
                }
            }
            finally
            {
                _isUpdating = false;
            }
        }

        private async void XTextBox_TextChanged(object sender, TextChangedEventArgs e)
        {
            await HandleTextChanged();
        }

        private async void YTextBox_TextChanged(object sender, TextChangedEventArgs e)
        {
            await HandleTextChanged();
        }

        private async void ZTextBox_TextChanged(object sender, TextChangedEventArgs e)
        {
            await HandleTextChanged();
        }

        private async void VectorTextBox_TextChanged(object sender, TextChangedEventArgs e)
        {
            if (_isUpdating || _getterFunc == null || IsReadOnly)
                return;

            try
            {
                string text = VectorTextBox.Text.Trim();

                // 如果文本为空或太短,不做处理
                if (string.IsNullOrWhiteSpace(text) || text.Length < 3)
                    return;

                _isUpdating = true;

                // 尝试解析为向量格式
                if (TryParseVector(text, out double x, out double y, out double z))
                {
                    await UpdateVectorValues(x, y, z);
                }
                // 尝试解析为变换矩阵格式(仅提取位移分量)
                else if (TryParseTransformMatrix(text, out double tx, out double ty, out double tz))
                {
                    await UpdateVectorValues(tx, ty, tz);
                }
                // 尝试作为单值解析每个字段(宽松模式)
                else if (TryParseLooseVector(text, out double lx, out double ly, out double lz))
                {
                    await UpdateVectorValues(lx, ly, lz);
                }
            }
            catch (Exception ex)
            {
                _logger.LogWarning(string.Format(Application.Current.FindResource("Vec3d_Update_Error").ToString(), ex.Message));
                _logger.LogError(ex, "{Message}", ex.Message);
            }
            finally
            {
                _isUpdating = false;
            }
        }

        private bool TryParseVector(string text, out double x, out double y, out double z)
        {
            x = y = z = 0;
            
            // 使用正则表达式匹配向量格式
            Match match = VectorRegex.Match(text);
            if (match.Success && match.Groups.Count >= 4)
            {
                if (double.TryParse(match.Groups[1].Value, out x) &&
                    double.TryParse(match.Groups[2].Value, out y) &&
                    double.TryParse(match.Groups[3].Value, out z))
                {
                    return true;
                }
            }
            return false;
        }

        private bool TryParseTransformMatrix(string text, out double tx, out double ty, out double tz)
        {
            tx = ty = tz = 0;
            
            // 移除所有换行和多余空格,便于匹配
            text = Regex.Replace(text, @"\s+", " ");
            
            // 尝试匹配变换矩阵格式
            Match match = MatrixRegex.Match(text);
            if (match.Success && match.Groups.Count >= 8)
            {
                // 变换矩阵的第4列通常是位移分量
                if (double.TryParse(match.Groups[4].Value, out tx) &&
                    double.TryParse(match.Groups[8].Value, out ty) &&
                    double.TryParse(match.Groups[12].Value, out tz))
                {
                    return true;
                }
            }
            
            // 尝试匹配更宽松的变换矩阵表示(例如从界面复制的数据)
            var numbers = Regex.Matches(text, @"(-?\d*\.?\d+)");
            if (numbers.Count >= 16)
            {
                // 假设这是一个4x4矩阵,提取位移分量(第4、8、12个数字)
                if (double.TryParse(numbers[3].Value, out tx) &&
                    double.TryParse(numbers[7].Value, out ty) &&
                    double.TryParse(numbers[11].Value, out tz))
                {
                    return true;
                }
            }
            
            return false;
        }

        private bool TryParseLooseVector(string text, out double x, out double y, out double z)
        {
            x = y = z = 0;
            
            // 移除所有非数字和小数点以外的字符,然后按空白分割
            string cleanText = Regex.Replace(text, @"[^\d\.\-\s,;]+", " ");
            string[] parts = Regex.Split(cleanText, @"[\s,;]+");
            
            // 尝试从分割后的部分获取三个数字
            var numbers = new System.Collections.Generic.List<double>();
            foreach (var part in parts)
            {
                if (!string.IsNullOrWhiteSpace(part) && double.TryParse(part, out double value))
                {
                    numbers.Add(value);
                    if (numbers.Count >= 3) break; // 最多取3个数字
                }
            }
            
            // 如果获取到了三个数字,就认为解析成功
            if (numbers.Count >= 3)
            {
                x = numbers[0];
                y = numbers[1];
                z = numbers[2];
                return true;
            }
            
            return false;
        }

        private async Task UpdateVectorValues(double x, double y, double z)
        {
            XTextBox.Text = x.ToString("F3");
            YTextBox.Text = y.ToString("F3");
            ZTextBox.Text = z.ToString("F3");

            var vec = _getterFunc?.Invoke();
            if (vec != null)
            {
                vec.X = x;
                vec.Y = y;
                vec.Z = z;

                if (_updateByContentFunc != null)
                {
                    await _updateByContentFunc();
                }
            }
        }

        private async Task HandleTextChanged()
        {
            if (_isUpdating || _getterFunc == null ||  IsReadOnly)
                return;

            try
            {
                _isUpdating = true;

                // 尝试解析每个文本框的值
                bool allValid = true;
                
                allValid &= double.TryParse(XTextBox.Text, out double x);
                allValid &= double.TryParse(YTextBox.Text, out double y);
                allValid &= double.TryParse(ZTextBox.Text, out double z);

                if (allValid)
                {
                    if (IsTextMode)
                    {
                        VectorTextBox.Text = $"{x},{y},{z}";
                    }

                    var vec = _getterFunc();
                    if (vec != null)
                    {
                        vec.X = x;
                        vec.Y = y;
                        vec.Z = z;

                        if(_updateByContentFunc != null)
                            await _updateByContentFunc();
                    }
                }
                else
                {
                    // 如果有无效输入,不进行更新但也不显示错误
                    // 这允许用户在输入过程中有不完整的状态
                }
            }
            catch (Exception ex)
            {
                // 记录异常但不中断用户操作
                _logger.LogWarning(string.Format(Application.Current.FindResource("Vec3d_Update_Error").ToString(), ex.Message));
                _logger.LogError(ex, "{Message}", ex.Message);
            }
            finally
            {
                _isUpdating = false;
            }
        }
    }
} 

Source Code Path

See HiNC App Anatomy for git repository links.

  • wwwroot-src/src/components/widgets/Vec3Input.vue — the widget itself: mode toggle, per-axis and single-field inputs, normalize button, and the local format and parse helpers
  • wwwroot-src/src/api/geometry.ts — the /api/Vec3d client the transformer editors use, and the Vec3dDto shape
  • Widget/Vec3dController.cs — the REST surface behind /api/Vec3d: New, NewWithValue, Get, Update, UpdateAt, ParseAndUpdate and Normalize. The SPA reaches Update.
  • wwwroot-src/src/components/topo/StaticRotationEditor.vue — the full binding contract in one place: v-model, @update:model-value, the normalize opt-in and its @normalize handler
  • wwwroot-src/src/components/topo/StaticTranslationEditor.vue — one instance, no normalize button
  • wwwroot-src/src/components/topo/DynamicRotationEditor.vue — axis with normalize, plus pivot
  • wwwroot-src/src/components/topo/DynamicTranslationEditor.vue — axis with normalize
  • wwwroot-src/src/components/geom/Box3dEditor.vue — four instances (min, max, dimension, center) whose readonly state follows the current edit mode, persisted through the Box3d endpoint
  • wwwroot-src/src/components/geom/StlFileEditor.vue — four display-only instances for the STL bounding-box pad
  • wwwroot-src/src/i18n/en/widgets.ts — the tooltip strings under widgets.vecInput

See Also

  • Mat4dControl Component — the sibling numeric-geometry editor, and the one a reader usually wants next
  • Numeric Input/Output — why NaN and the infinities are text on the wire, and how the three inputs differ