feat: 过弯检测工具(纯解析几何公式)

This commit is contained in:
tian 2026-07-08 12:55:47 +08:00
parent 70fee3cf3a
commit 4f9206da2a
9 changed files with 438 additions and 1 deletions

View File

@ -62,6 +62,7 @@
<ItemGroup>
<Compile Include="UnitTests\Core\AliasTreeTests.cs" />
<Compile Include="UnitTests\Core\PathHelperTests.cs" />
<Compile Include="UnitTests\Core\CornerTurnCheckHelperTests.cs" />
<Compile Include="UnitTests\Core\SelectionClipBoxLockStateTests.cs" />
<Compile Include="UnitTests\Core\PathPlanningManagerHoistingCompletionTests.cs" />
<Compile Include="UnitTests\Core\PathPersistenceTests.cs" />

View File

@ -278,6 +278,9 @@
<Compile Include="src\UI\WPF\Views\EditCoordinatesWindow.xaml.cs">
<DependentUpon>EditCoordinatesWindow.xaml</DependentUpon>
</Compile>
<Compile Include="src\UI\WPF\Views\CornerTurnCheckDialog.xaml.cs">
<DependentUpon>CornerTurnCheckDialog.xaml</DependentUpon>
</Compile>
<Compile Include="src\UI\WPF\Views\ModelItemBoundsWindow.xaml.cs">
<DependentUpon>ModelItemBoundsWindow.xaml</DependentUpon>
</Compile>
@ -342,6 +345,7 @@
<Compile Include="src\Utils\NavisworksSelectionHelper.cs" />
<Compile Include="src\Utils\NavisworksToDMesh3Converter.cs" />
<Compile Include="src\Utils\UnitsConverter.cs" />
<Compile Include="src\Utils\CornerTurnCheckHelper.cs" />
<Compile Include="src\Utils\VersionInfo.cs" />
<!-- Coordinate System -->
<Compile Include="src\Utils\CoordinateSystem\AlignToGroundMinCrossSectionYawSearcher.cs" />
@ -407,6 +411,10 @@
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>
</Page>
<Page Include="src\UI\WPF\Views\CornerTurnCheckDialog.xaml">
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>
</Page>
<Page Include="src\UI\WPF\Views\ModelItemBoundsWindow.xaml">
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>

View File

@ -0,0 +1,99 @@
using System;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using NavisworksTransport.Utils;
namespace NavisworksTransport.UnitTests.Core
{
[TestClass]
public class CornerTurnCheckHelperTests
{
[TestMethod]
public void EqualWidth_MaxLength_ShouldPass()
{
var L = 3.0 * Math.Sqrt(2) - 1.5 - 0.01;
Assert.IsTrue(CornerTurnCheckHelper.CanPass(L, 1.5, 3.0, 3.0, out _, out _));
}
[TestMethod]
public void EqualWidth_TooLong_ShouldNotPass()
{
Assert.IsFalse(CornerTurnCheckHelper.CanPass(2.5, 1.0, 2.0, 2.0, out _, out _));
}
[TestMethod]
public void EqualWidth_Short_ShouldPass()
{
Assert.IsTrue(CornerTurnCheckHelper.CanPass(1.0, 0.5, 2.0, 2.0, out _, out _));
}
[TestMethod]
public void StraightPass_ShouldPass()
{
Assert.IsTrue(CornerTurnCheckHelper.CanPass(2.5, 0.3, 2.5, 3.0, out _, out _));
}
[TestMethod]
public void WidthExceedsBoth_ShouldNotPass()
{
Assert.IsFalse(CornerTurnCheckHelper.CanPass(3.0, 4.0, 2.0, 2.5, out _, out _));
}
[TestMethod]
public void ZeroOrNegativeInput_ShouldReturnFalse()
{
Assert.IsFalse(CornerTurnCheckHelper.CanPass(0, 1, 2, 2, out _, out _));
Assert.IsFalse(CornerTurnCheckHelper.CanPass(2, 0, 2, 2, out _, out _));
Assert.IsFalse(CornerTurnCheckHelper.CanPass(2, 1, 0, 2, out _, out _));
Assert.IsFalse(CornerTurnCheckHelper.CanPass(2, 1, 2, 0, out _, out _));
Assert.IsFalse(CornerTurnCheckHelper.CanPass(-1, 1, 2, 2, out _, out _));
}
[TestMethod]
public void UnequalWidth_Fits_ShouldPass()
{
Assert.IsTrue(CornerTurnCheckHelper.CanPass(0.8, 0.5, 3.0, 1.5, out _, out _));
}
[TestMethod]
public void VeryDifferentWidths_ShouldUseNarrower()
{
Assert.IsTrue(CornerTurnCheckHelper.CanPass(0.1, 0.55, 10.0, 0.6, out _, out _));
}
[TestMethod]
public void HugeModule_ShouldNotPass()
{
Assert.IsFalse(CornerTurnCheckHelper.CanPass(20.0, 5.0, 2.0, 2.0, out _, out _));
}
[TestMethod]
public void UserCase_LongerThanEntry_ShouldNotPass()
{
Assert.IsFalse(CornerTurnCheckHelper.CanPass(4.9, 2.0, 4.8, 2.75, out _, out _));
}
[TestMethod]
public void UserCase_WiderThanExit_ShouldNotPass()
{
Assert.IsFalse(CornerTurnCheckHelper.CanPass(4.8, 2.8, 3.0, 2.75, out _, out _));
}
[TestMethod]
public void EqualWidth_Square_ShouldPass()
{
Assert.IsTrue(CornerTurnCheckHelper.CanPass(1.0, 1.0, 2.0, 2.0, out _, out _));
}
[TestMethod]
public void EntryExact_ShouldPass()
{
Assert.IsTrue(CornerTurnCheckHelper.CanPass(2.75, 2.0, 5.0, 2.75, out _, out _));
}
[TestMethod]
public void Square_WidthEqualsCorridor_ShouldPass()
{
Assert.IsTrue(CornerTurnCheckHelper.CanPass(2.0, 2.0, 3.0, 2.75, out _, out _));
}
}
}

View File

@ -209,6 +209,9 @@ NavisworksTransport 共享样式资源字典 - Navisworks 2026风格
M17.65,6.35C16.2,4.9 14.21,4 12,4C7.58,4 4.01,7.58 4.01,12C4.01,16.42 7.58,20 12,20C15.73,20 18.84,17.45 19.73,14H17.65C16.83,16.33 14.61,18 12,18C8.69,18 6,15.31 6,12C6,8.69 8.69,6 12,6C13.66,6 15.14,6.69 16.22,7.78L13,11H20V4L17.65,6.35Z
</Geometry>
<!-- L形转角图标 (过弯检测) -->
<Geometry x:Key="CornerCheckIconGeometry">M3,17 L17,17 L17,3</Geometry>
<!-- 靶心/准心图标 (测量工具) -->
<Geometry x:Key="TargetIconGeometry">
M12,2C6.48,2 2,6.48 2,12C2,17.52 6.48,22 12,22C17.52,22 22,17.52 22,12C22,6.48 17.52,2 12,2ZM12,20C7.59,20 4,16.41 4,12C4,7.59 7.59,4 12,4C16.41,4 20,7.59 20,12C20,16.41 16.41,20 12,20ZM12,6C8.69,6 6,8.69 6,12C6,15.31 8.69,18 12,18C15.31,18 18,15.31 18,12C18,8.69 15.31,6 12,6ZM12,16C9.79,16 8,14.21 8,12C8,9.79 9.79,8 12,8C14.21,8 16,9.79 16,12C16,14.21 14.21,16 12,16Z

View File

@ -0,0 +1,123 @@
<Window x:Class="NavisworksTransport.UI.WPF.Views.CornerTurnCheckDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:converters="clr-namespace:NavisworksTransport.UI.WPF.Converters"
Title="过弯检测" Height="520" Width="420"
WindowStartupLocation="CenterScreen"
ResizeMode="NoResize"
ShowInTaskbar="False"
Topmost="True"
Background="White">
<Window.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="pack://application:,,,/TransportPlugin;component/src/UI/WPF/Resources/NavisworksStyles.xaml"/>
</ResourceDictionary.MergedDictionaries>
<converters:EditableNumberConverter x:Key="EditableNumberConverter"/>
</ResourceDictionary>
</Window.Resources>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- 标题栏 -->
<Border Grid.Row="0" Background="#FFF8FBFF" BorderBrush="#FFD4E7FF" BorderThickness="0,0,0,1" Padding="20,15">
<StackPanel>
<TextBlock Text="过弯检测" FontWeight="SemiBold" FontSize="14" Foreground="#FF2B579A"/>
<TextBlock Text="检查矩形模块能否通过 L 形通道" FontSize="10" Foreground="#FF666666" Margin="0,3,0,0"/>
</StackPanel>
</Border>
<!-- 输入区域 -->
<Border Grid.Row="1" Padding="20,15">
<StackPanel>
<Border Background="#FFF0F7FF" BorderBrush="#FFD4E7FF" BorderThickness="1" Padding="12,10" Margin="0,0,0,15">
<TextBlock Text="模块在转角处的投影宽度不能超过通道宽度,输入尺寸后计算最优化角度下的通过性。"
FontSize="10" Foreground="#FF666666" TextWrapping="Wrap"/>
</Border>
<!-- 入口通道宽度 -->
<Grid Margin="0,0,0,12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="100"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="30"/>
</Grid.ColumnDefinitions>
<TextBlock Text="入口通道宽度 C₁:" VerticalAlignment="Center" FontSize="11" Foreground="#FF333333"/>
<TextBox Grid.Column="1" x:Name="EntryWidthBox" Height="28"
VerticalContentAlignment="Center" Padding="8,0" FontSize="11"
BorderBrush="#FFCCCCCC" BorderThickness="1"/>
<TextBlock Grid.Column="2" Text="米" VerticalAlignment="Center" FontSize="11" Foreground="#FF666666" Margin="5,0,0,0"/>
</Grid>
<!-- 出口通道宽度 -->
<Grid Margin="0,0,0,12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="100"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="30"/>
</Grid.ColumnDefinitions>
<TextBlock Text="出口通道宽度 C₂:" VerticalAlignment="Center" FontSize="11" Foreground="#FF333333"/>
<TextBox Grid.Column="1" x:Name="ExitWidthBox" Height="28"
VerticalContentAlignment="Center" Padding="8,0" FontSize="11"
BorderBrush="#FFCCCCCC" BorderThickness="1"/>
<TextBlock Grid.Column="2" Text="米" VerticalAlignment="Center" FontSize="11" Foreground="#FF666666" Margin="5,0,0,0"/>
</Grid>
<!-- 分隔线 -->
<Border Height="1" Background="#FFEEEEEE" Margin="0,0,0,12"/>
<!-- 模块长度 -->
<Grid Margin="0,0,0,12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="100"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="30"/>
</Grid.ColumnDefinitions>
<TextBlock Text="模块长度 L:" VerticalAlignment="Center" FontSize="11" Foreground="#FF333333"/>
<TextBox Grid.Column="1" x:Name="LengthBox" Height="28"
VerticalContentAlignment="Center" Padding="8,0" FontSize="11"
BorderBrush="#FFCCCCCC" BorderThickness="1"/>
<TextBlock Grid.Column="2" Text="米" VerticalAlignment="Center" FontSize="11" Foreground="#FF666666" Margin="5,0,0,0"/>
</Grid>
<!-- 模块宽度 -->
<Grid Margin="0,0,0,12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="100"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="30"/>
</Grid.ColumnDefinitions>
<TextBlock Text="模块宽度 W:" VerticalAlignment="Center" FontSize="11" Foreground="#FF333333"/>
<TextBox Grid.Column="1" x:Name="WidthBox" Height="28"
VerticalContentAlignment="Center" Padding="8,0" FontSize="11"
BorderBrush="#FFCCCCCC" BorderThickness="1"/>
<TextBlock Grid.Column="2" Text="米" VerticalAlignment="Center" FontSize="11" Foreground="#FF666666" Margin="5,0,0,0"/>
</Grid>
<!-- 结果区 -->
<Border x:Name="ResultBorder" Background="#FFF0FFF0" BorderBrush="#FF4CAF50" BorderThickness="1" Padding="12,10" Margin="0,5,0,0"
Visibility="Collapsed">
<StackPanel>
<TextBlock x:Name="ResultTitle" FontWeight="SemiBold" FontSize="12" Foreground="#FF2E7D32"/>
<TextBlock x:Name="ResultDetail" FontSize="10" Foreground="#FF666666" Margin="0,3,0,0"/>
</StackPanel>
</Border>
</StackPanel>
</Border>
<!-- 按钮栏 -->
<Border Grid.Row="2" Background="#FFF8FBFF" BorderBrush="#FFD4E7FF" BorderThickness="0,1,0,0" Padding="20,12">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
<Button Content="计算" Click="OnCalculateClick"
Style="{StaticResource ActionButtonStyle}" Width="90" Height="32" Margin="0,0,10,0"/>
<Button Content="关闭" Click="OnCloseClick"
Style="{StaticResource SecondaryButtonStyle}" Width="90" Height="32"/>
</StackPanel>
</Border>
</Grid>
</Window>

View File

@ -0,0 +1,80 @@
using System;
using System.Windows;
using System.Windows.Media;
using NavisworksTransport.Utils;
namespace NavisworksTransport.UI.WPF.Views
{
public partial class CornerTurnCheckDialog : Window
{
public CornerTurnCheckDialog()
{
InitializeComponent();
}
private void OnCalculateClick(object sender, RoutedEventArgs e)
{
if (!TryParseInputs(out var c1, out var c2, out var length, out var width))
return;
var pass = CornerTurnCheckHelper.CanPass(length, width, c1, c2,
out var margin, out var maxLength);
ResultBorder.Visibility = Visibility.Visible;
if (pass)
{
ResultBorder.Background = new SolidColorBrush(Color.FromRgb(240, 255, 240));
ResultBorder.BorderBrush = new SolidColorBrush(Color.FromRgb(76, 175, 80));
ResultTitle.Text = "✅ 可通过";
ResultTitle.Foreground = new SolidColorBrush(Color.FromRgb(46, 125, 50));
ResultDetail.Text = $"余量: {margin:F2} 米\n最大可通过长度: {maxLength:F2} 米";
}
else
{
ResultBorder.Background = new SolidColorBrush(Color.FromRgb(255, 240, 240));
ResultBorder.BorderBrush = new SolidColorBrush(Color.FromRgb(244, 67, 54));
ResultTitle.Text = "❌ 不可通过";
ResultTitle.Foreground = new SolidColorBrush(Color.FromRgb(183, 28, 28));
ResultDetail.Text = $"超出: {-margin:F2} 米\n当前条件下最大可通过长度: {maxLength:F2} 米";
}
}
private void OnCloseClick(object sender, RoutedEventArgs e)
{
Close();
}
private bool TryParseInputs(out double c1, out double c2, out double length, out double width)
{
c1 = c2 = length = width = 0;
if (!TryParseTextBox(EntryWidthBox, out c1, "入口通道宽度")) return false;
if (!TryParseTextBox(ExitWidthBox, out c2, "出口通道宽度")) return false;
if (!TryParseTextBox(LengthBox, out length, "模块长度")) return false;
if (!TryParseTextBox(WidthBox, out width, "模块宽度")) return false;
return true;
}
private static bool TryParseTextBox(System.Windows.Controls.TextBox box, out double value, string name)
{
value = 0;
var text = box.Text.Trim();
if (string.IsNullOrEmpty(text))
{
MessageBox.Show($"请输入{name}。", "输入错误", MessageBoxButton.OK, MessageBoxImage.Warning);
box.Focus();
return false;
}
if (!double.TryParse(text, out value) || value <= 0)
{
MessageBox.Show($"请输入有效的{name}(正数)。", "输入错误", MessageBoxButton.OK, MessageBoxImage.Warning);
box.Focus();
box.SelectAll();
return false;
}
return true;
}
}
}

View File

@ -311,6 +311,7 @@ NavisworksTransport 路径编辑页签视图 - 采用与动画控制和分层管
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="路径点列表" VerticalAlignment="Center" FontSize="11" Foreground="#FF666666"/>
<Button Grid.Column="1"
@ -318,11 +319,22 @@ NavisworksTransport 路径编辑页签视图 - 采用与动画控制和分层管
Click="OnMeasureBoundsButtonClick"
Style="{StaticResource IconButtonStyle}"
Width="24"
Height="24">
Height="24"
Margin="0,0,2,0">
<Path Data="{StaticResource TargetIconGeometry}"
Fill="{StaticResource NavisworksPrimaryBrush}"
Width="16" Height="16" Stretch="Uniform"/>
</Button>
<Button Grid.Column="2"
ToolTip="过弯检测 — 检查模块能否通过 L 形通道"
Click="OnCornerTurnCheckClick"
Style="{StaticResource IconButtonStyle}"
Width="24"
Height="24">
<Path Data="{StaticResource CornerCheckIconGeometry}"
Fill="{StaticResource NavisworksPrimaryBrush}"
Width="16" Height="16" Stretch="Uniform"/>
</Button>
</Grid>
<!-- 路径点列表 -->

View File

@ -286,6 +286,19 @@ namespace NavisworksTransport.UI.WPF.Views
}
}
private void OnCornerTurnCheckClick(object sender, RoutedEventArgs e)
{
try
{
var dialog = new CornerTurnCheckDialog();
dialog.ShowDialog();
}
catch (Exception ex)
{
LogManager.Error($"打开过弯检测对话框失败: {ex.Message}");
}
}
/// <summary>
/// 包围盒信息窗口实例(保持引用以防止被GC回收)
/// </summary>

View File

@ -0,0 +1,98 @@
using System;
namespace NavisworksTransport.Utils
{
public static class CornerTurnCheckHelper
{
public static bool CanPass(double length, double width, double entryWidth, double exitWidth,
out double margin, out double maxLength)
{
margin = 0;
maxLength = 0;
if (length <= 0 || width <= 0 || entryWidth <= 0 || exitWidth <= 0)
return false;
// 模块最短边必须能同时通过两条通道
var minDim = Math.Min(length, width);
if (minDim > entryWidth || minDim > exitWidth)
return false;
// 转角通过性解析判断
var r = Math.Sqrt(length * length + width * width);
var phi = Math.Atan2(width, length);
var sinEntryRatio = entryWidth / r;
var cosExitRatio = exitWidth / r;
// 入口侧:最大 θ 满足 Y_span ≤ C₁
var thetaYmax = double.NaN;
if (sinEntryRatio >= 1)
thetaYmax = Math.PI / 2; // 从不超出
else
{
thetaYmax = Math.Asin(sinEntryRatio) - phi;
if (thetaYmax < 0) thetaYmax = 0;
if (thetaYmax > Math.PI / 2) thetaYmax = Math.PI / 2;
}
// 出口侧:最小 θ 满足 X_span ≤ C₂
var thetaXmin = double.NaN;
if (cosExitRatio >= 1)
thetaXmin = 0; // 从不超出
else
{
thetaXmin = Math.Acos(cosExitRatio) + phi;
if (thetaXmin < 0) thetaXmin = 0;
if (thetaXmin > Math.PI / 2) thetaXmin = Math.PI / 2;
}
// 需要同时满足两条约束的区间有交集
var canPass = thetaYmax >= thetaXmin;
if (canPass)
{
// 余量 = 在交集中心处两个约束的最小剩余量
var thetaMid = (thetaYmax + thetaXmin) / 2;
var cos = Math.Cos(thetaMid);
var sin = Math.Sin(thetaMid);
var xSpan = length * cos + width * sin;
var ySpan = length * sin + width * cos;
margin = Math.Min(exitWidth - xSpan, entryWidth - ySpan);
maxLength = FindMaxLength(width, entryWidth, exitWidth);
}
return canPass;
}
/// <summary> 二分法求最大可通过长度,使用相同的解析条件 </summary>
private static double FindMaxLength(double width, double c1, double c2)
{
double lo = 0, hi = Math.Max(c1, c2) * 4;
for (int i = 0; i < 50; i++)
{
var mid = (lo + hi) / 2;
if (CheckLengthFeasible(mid, width, c1, c2))
lo = mid;
else
hi = mid;
}
return (lo + hi) / 2;
}
private static bool CheckLengthFeasible(double length, double width, double c1, double c2)
{
var r = Math.Sqrt(length * length + width * width);
var phi = Math.Atan2(width, length);
var thetaYmax = c1 >= r ? Math.PI / 2
: Math.Min(Math.Max(Math.Asin(c1 / r) - phi, 0), Math.PI / 2);
var thetaXmin = c2 >= r ? 0
: Math.Min(Math.Max(Math.Acos(c2 / r) + phi, 0), Math.PI / 2);
return thetaYmax >= thetaXmin;
}
}
}