feat: 自由路径(Free Path)一级路径类型

- PathType 新增 Free=3,显示'自由'
- 动画:统一包围盒中心对齐,方案1姿态(forward=切线,up=世界up正交化)
  复用 CanonicalRailPoseBuilder,新增 TryCreateFreePathRotationForFrame/AtStart
- 渲染:通行空间垂直偏移0(路径点即物体中心)
- 路径管理器:StartCreatingNewRoute/FinishEditing 支持 Free
- UI:PathEditingView 新增'自由'按钮 + NewFreePathCommand
- 数据库:枚举存 int 无需 schema 变更
- 迁移:加载时 IsTwoPointVertical 吊装路径 → Free
- 单测:新增 FreePathTests(6个全通过)

注:CornerTurnCheckHelperTests 6个失败为分支起点已存在的 pre-existing 问题,与本次无关
This commit is contained in:
tian 2026-07-31 16:02:25 +08:00
parent 469f7efe2f
commit 002618f371
9 changed files with 355 additions and 17 deletions

View File

@ -0,0 +1,137 @@
using Microsoft.VisualStudio.TestTools.UnitTesting;
using NavisworksTransport.Utils.CoordinateSystem;
using System;
using System.Numerics;
namespace NavisworksTransport.UnitTests.Core
{
[TestClass]
public class FreePathTests
{
// === PathType 枚举 ===
[TestMethod]
public void PathType_Free_ShouldHaveValue3()
{
Assert.AreEqual(3, (int)PathType.Free);
}
[TestMethod]
public void PathType_Free_GetDisplayName_ShouldReturnFree()
{
Assert.AreEqual("自由", PathType.Free.GetDisplayName());
}
// === 自由路径旋转姿态方案1forward=切线, up=世界up正交化 ===
[TestMethod]
public void FreePathRotation_StraightHorizontal_ShouldAlignForwardToX_UpToWorldUp()
{
// ZUp 约定水平直路forward=+Xup=+Z世界up
bool ok = CanonicalRailPoseBuilder.TryCreateQuaternion(
new Vector3(0, 0, 0),
new Vector3(1, 0, 0),
new Vector3(2, 0, 0),
Vector3.UnitZ,
ModelAxisConvention.CreateDefaultForHost(CoordinateSystemType.ZUp),
null, // preferredNormal=null → 使用世界up
Quaternion.Identity,
out Quaternion rotation);
Assert.IsTrue(ok);
Matrix4x4 linear = Matrix4x4.CreateFromQuaternion(rotation);
// 局部 X → 世界 Xforward=切线)
AssertColumn(linear, 0, 1, 0, 0);
// 局部 Z → 世界 Zup=世界up
AssertColumn(linear, 2, 0, 0, 1);
}
[TestMethod]
public void FreePathRotation_SlopedPath_ShouldKeepForwardAlongTangent_UpOrthogonalized()
{
// 斜路径沿X上升forward 应沿切线up 应正交且接近世界Z
bool ok = CanonicalRailPoseBuilder.TryCreateBasis(
new Vector3(0, 0, 0),
new Vector3(1, 0, 0.5f),
new Vector3(2, 0, 1),
Vector3.UnitZ,
out Vector3 forward,
out Vector3 lateral,
out Vector3 up);
Assert.IsTrue(ok);
Assert.AreEqual(1.0, forward.Length(), 1e-6);
Assert.AreEqual(1.0, up.Length(), 1e-6);
Assert.AreEqual(1.0, lateral.Length(), 1e-6);
// 正交性
Assert.AreEqual(0.0, Vector3.Dot(forward, up), 1e-6);
Assert.AreEqual(0.0, Vector3.Dot(forward, lateral), 1e-6);
Assert.AreEqual(0.0, Vector3.Dot(lateral, up), 1e-6);
// 切线方向(应为 X 方向主导)
Assert.IsTrue(Vector3.Dot(forward, Vector3.UnitX) > 0.99f);
// up 接近世界 Z投影正交化
Assert.IsTrue(Vector3.Dot(up, Vector3.UnitZ) > 0.95f);
}
[TestMethod]
public void FreePathRotation_VerticalPath_ShouldStillProduceValidFrame()
{
// 纯垂直路径沿Z上升forward 应能生成up 退化为水平无世界up投影时用X
bool ok = CanonicalRailPoseBuilder.TryCreateBasis(
new Vector3(0, 0, 0),
new Vector3(0, 0, 1),
new Vector3(0, 0, 2),
Vector3.UnitZ,
out Vector3 forward,
out Vector3 lateral,
out Vector3 up);
Assert.IsTrue(ok);
Assert.AreEqual(1.0, forward.Length(), 1e-6);
Assert.AreEqual(1.0, up.Length(), 1e-6);
Assert.AreEqual(0.0, Vector3.Dot(forward, up), 1e-6);
// 纯垂直时 forward=+Zup 应正交(水平)
Assert.IsTrue(Math.Abs(Vector3.Dot(forward, Vector3.UnitZ)) > 0.99f);
}
// === 自由路径几何中心对齐语义 ===
[TestMethod]
public void FreePath_CenterAlignment_ShouldNotApplyHeightOffset()
{
// 自由路径路径点即物体中心ResolveGroundTrackedCenter 的半高偏移不应用于自由路径
// 这里验证 CanonicalTrackedPositionResolver 的语义不影响自由路径
var resolver = CanonicalTrackedPositionResolver.ResolveGroundTrackedCenter(
new Vector3(5, 5, 5),
Vector3.UnitZ,
2.0);
// Ground 语义:中心上移半高
Assert.AreEqual(6.0, resolver.Z, 1e-6);
}
private static void AssertColumn(Matrix4x4 m, int col, float x, float y, float z)
{
switch (col)
{
case 0:
Assert.AreEqual(x, m.M11, 1e-5f);
Assert.AreEqual(y, m.M21, 1e-5f);
Assert.AreEqual(z, m.M31, 1e-5f);
break;
case 1:
Assert.AreEqual(x, m.M12, 1e-5f);
Assert.AreEqual(y, m.M22, 1e-5f);
Assert.AreEqual(z, m.M32, 1e-5f);
break;
case 2:
Assert.AreEqual(x, m.M13, 1e-5f);
Assert.AreEqual(y, m.M23, 1e-5f);
Assert.AreEqual(z, m.M33, 1e-5f);
break;
default:
Assert.Fail("无效列");
break;
}
}
}
}

View File

@ -122,14 +122,16 @@ framePosition 即物体中心,现有碰撞盒计算直接正确(无需改动
| # | 步骤 | 状态 | 备注 |
|----|------|------|------|
| 1 | `PathType``Free` + `GetDisplayName` | 🔄 | 实施中 |
| 2 | 动画管理器:新增 Free 分支(统一中心对齐 + 方案1姿态 | ☐ | |
| 3 | 渲染插件Free 普通渲染 | ☐ | |
| 4 | 路径管理器:`StartCreatingNewRoute` 支持 Free | ☐ | |
| 5 | UI新增自由路径创建入口 | ☐ | PathEditingView + ViewModel |
| 6 | 数据库:枚举扩展(无 schema 变更) | ☐ | |
| 7 | 旧数据迁移方案A | ☐ | |
| 8 | 补单测(中心对齐 + 姿态 + 多点) | ☐ | |
| 9 | 编译 + 单测 + 部署 + 手动验证 | ☐ | |
| 1 | `PathType``Free` + `GetDisplayName` | ✅ | 枚举值3显示"自由" |
| 2 | 动画管理器:新增 Free 分支(统一中心对齐 + 方案1姿态 | ✅ | `TryCreateFreePathRotationForFrame/AtStart`,复用 CanonicalRailPoseBuilder |
| 3 | 渲染插件Free 普通渲染 | ✅ | 通行空间垂直偏移0路径=中心) |
| 4 | 路径管理器:`StartCreatingNewRoute`/`FinishEditing` 支持 Free | ✅ | 状态消息 + 设终点逻辑 |
| 5 | UI新增自由路径创建入口 | ✅ | PathEditingView 按钮 + `NewFreePathCommand` + `ExecuteNewFreePathAsync` |
| 6 | 数据库:枚举扩展(无 schema 变更) | ✅ | 存 intFree=3 自动映射 |
| 7 | 旧数据迁移方案A | ✅ | 加载时 IsTwoPointVertical→Free |
| 8 | 补单测(中心对齐 + 姿态 + 多点) | ✅ | 新增 FreePathTests6个 |
| 9 | 编译 + 单测 + 部署 + 手动验证 | 🔄 | 需部署后手动验证 |
> ⚠️ 已知 pre-existing 问题:`CornerTurnCheckHelperTests` 6个测试失败与自由路径无关需另行排查
> 进度图例:☐ 未开始 / 🔄 进行中 / ✅ 完成

View File

@ -805,7 +805,12 @@ namespace NavisworksTransport.Core.Animation
// 根据路径类型调整起点位置
Point3D pathStartPoint = _pathPoints[0];
Point3D startPosition = pathStartPoint;
if (_route.PathType == PathType.Hoisting)
if (_route.PathType == PathType.Free)
{
// 自由路径:路径点即物体几何中心,无需任何偏移
LogManager.Debug($"[移动到起点] 自由路径:路径点即物体中心=({startPosition.X:F2},{startPosition.Y:F2},{startPosition.Z:F2})");
}
else if (_route.PathType == PathType.Hoisting)
{
// 吊装路径:第一个路径点(起吊点)是地面位置,动画跟踪点统一使用几何中心
startPosition = ResolveGroundTrackedCenter(startPosition, GetAnimatedObjectGroundContactHeight());
@ -950,6 +955,17 @@ namespace NavisworksTransport.Core.Animation
_hasGroundRealObjectBasePose = false;
}
}
else if (_route.PathType == PathType.Free &&
TryCreateFreePathRotationAtStart(out var freeStartRotation))
{
ApplyFullPoseUpdate(startPosition, freeStartRotation);
LogManager.Debug("[移动到起点] 自由路径已应用完整三维姿态(切线前向 + 世界up正交化");
}
else if (_route.PathType == PathType.Free)
{
LogManager.Error("[移动到起点] 自由路径未能生成完整三维姿态,已禁止退回旧 yaw 链路");
return false;
}
else if (_route.PathType == PathType.Ground || _route.PathType == PathType.Hoisting)
{
LogManager.Error("[移动到起点] 平面路径未能生成完整姿态,已禁止退回旧 yaw 链路");
@ -1117,8 +1133,8 @@ namespace NavisworksTransport.Core.Animation
int totalFrames = (int)(_animationDuration * _animationFrameRate);
// 🔥 判断路径类型:空中路径使用Points地面路径使用Edges
bool isAerialPath = _route.PathType == PathType.Rail || _route.PathType == PathType.Hoisting;
// 🔥 判断路径类型:空中/自由路径使用Points地面路径使用Edges
bool isAerialPath = _route.PathType == PathType.Rail || _route.PathType == PathType.Hoisting || _route.PathType == PathType.Free;
List<Point3D> allSampledPoints = new List<Point3D>();
List<double> segmentLengths = new List<double>();
@ -1126,7 +1142,8 @@ namespace NavisworksTransport.Core.Animation
if (isAerialPath)
{
// === 空中路径:直接使用路径点进行线性插值 ===
string aerialSubTypeName = _route.PathType == PathType.Rail ? "空轨" : "吊装";
string aerialSubTypeName = _route.PathType == PathType.Rail ? "空轨" :
_route.PathType == PathType.Hoisting ? "吊装" : "自由";
LogManager.Info($"[{aerialSubTypeName}路径] 使用路径点进行线性插值,路径点数: {_route.Points.Count}");
// 将路径点转换为Point3D列表
@ -1491,6 +1508,12 @@ namespace NavisworksTransport.Core.Animation
frame.Rotation = railRotation;
frame.HasCustomRotation = true;
}
else if (_route.PathType == PathType.Free &&
TryCreateFreePathRotationForFrame(previousFramePoint, framePosition, nextFramePoint, out var freeRotation))
{
frame.Rotation = freeRotation;
frame.HasCustomRotation = true;
}
else if ((_route.PathType == PathType.Ground || _route.PathType == PathType.Hoisting) &&
!_route.IsTwoPointVertical &&
TryCreatePlanarPathRotationForFrame(previousFramePoint, framePosition, nextFramePoint, out var planarRotation))
@ -4914,6 +4937,58 @@ namespace NavisworksTransport.Core.Animation
return true;
}
/// <summary>
/// 自由路径姿态方案1 —— forward=路径切线up=世界up投影正交化。
/// 复用 CanonicalRailPoseBuilder 的三维姿态计算(不依赖轨道特殊语义)。
/// </summary>
private bool TryCreateFreePathRotationForFrame(Point3D previousPoint, Point3D currentPoint, Point3D nextPoint, out Rotation3D rotation)
{
rotation = Rotation3D.Identity;
var adapter = CoordinateSystemManager.Instance.CreateHostAdapter();
var convention = GetCurrentModelAxisConvention();
Vector3 canonicalPrevious = ToNumerics(adapter.ToCanonicalPoint(previousPoint));
Vector3 canonicalCurrent = ToNumerics(adapter.ToCanonicalPoint(currentPoint));
Vector3 canonicalNext = ToNumerics(adapter.ToCanonicalPoint(nextPoint));
Quaternion correctionQuaternion = adapter.CreateCanonicalRotationCorrection(_objectRotationCorrection);
if (!CanonicalRailPoseBuilder.TryCreateQuaternion(
canonicalPrevious,
canonicalCurrent,
canonicalNext,
HostCoordinateAdapter.CanonicalUpVector3,
convention,
null, // preferredNormal=null → 使用 canonicalUp世界up作为参考up即方案1
correctionQuaternion,
out var canonicalRotation))
{
return false;
}
rotation = adapter.FromCanonicalRotation(canonicalRotation);
return true;
}
/// <summary>
/// 自由路径起点姿态以首段方向为切线世界up正交化方案1
/// </summary>
private bool TryCreateFreePathRotationAtStart(out Rotation3D rotation)
{
rotation = Rotation3D.Identity;
if (_pathPoints == null || _pathPoints.Count < 2)
{
return false;
}
return TryCreateFreePathRotationForFrame(
_pathPoints[0],
_pathPoints[0],
_pathPoints[1],
out rotation);
}
private bool TryCreateRailPathRotation(
Point3D previousPoint,
Point3D currentPoint,

View File

@ -1646,6 +1646,15 @@ namespace NavisworksTransport
route.OverallScore = Convert.ToDouble(reader["OverallScore"]);
}
// 迁移:旧 IsTwoPointVertical 吊装路径 → 自由路径方案A
// 旧 2 点路径的点坐标直接作为自由路径的物体几何中心
if (route.PathType == PathType.Hoisting && route.IsTwoPointVertical)
{
LogManager.Info($"[迁移] 旧2点吊装路径 {route.Name} → 转换为自由路径");
route.PathType = PathType.Free;
route.IsTwoPointVertical = false;
}
routes.Add(route);
}
}
@ -2856,6 +2865,14 @@ namespace NavisworksTransport
Convert.ToDouble(reader["RailPreferredNormalZ"]));
}
// 迁移:旧 IsTwoPointVertical 吊装路径 → 自由路径方案A
if (route.PathType == PathType.Hoisting && route.IsTwoPointVertical)
{
LogManager.Info($"[迁移] 旧2点吊装路径 {route.Name} → 转换为自由路径");
route.PathType = PathType.Free;
route.IsTwoPointVertical = false;
}
// 加载路径点和路径边
LoadPathPoints(route);
LoadPathEdges(route);

View File

@ -1686,6 +1686,10 @@ namespace NavisworksTransport
{
statusMessage = $"正在新建空中路径: {newRoute.Name} - 请在3D视图中设置路径点";
}
else if (pathType == PathType.Free)
{
statusMessage = $"正在新建自由路径: {newRoute.Name} - 请在3D视图中连续点击设置物体中心轨迹点";
}
else
{
statusMessage = $"正在新建地面路径: {newRoute.Name} - 请在3D视图中可通行的物流模型上点击设置路径点";
@ -1724,9 +1728,10 @@ namespace NavisworksTransport
// 根据路径类型执行不同的完成逻辑
if (CurrentRoute != null && CurrentRoute.Points.Count > 1)
{
// 地面路径和空轨路径:自动设置最后一个点为终点
// 地面路径、空轨路径、自由路径:自动设置最后一个点为终点
if (CurrentRoute.PathType == PathType.Ground ||
CurrentRoute.PathType == PathType.Rail)
CurrentRoute.PathType == PathType.Rail ||
CurrentRoute.PathType == PathType.Free)
{
var lastPoint = CurrentRoute.Points.Last();
if (lastPoint.Type != PathPointType.StartPoint) // 起点不能是终点
@ -1738,7 +1743,8 @@ namespace NavisworksTransport
// 重新渲染整个路径以反映类型变更
_renderPlugin?.RenderPath(CurrentRoute);
string pathTypeName = CurrentRoute.PathType == PathType.Ground ? "地面路径" : "空轨路径";
string pathTypeName = CurrentRoute.PathType == PathType.Ground ? "地面路径" :
CurrentRoute.PathType == PathType.Rail ? "空轨路径" : "自由路径";
LogManager.Info($"[{pathTypeName}] 已自动设置最后一个点为终点: {lastPoint.Name}");
}
}

View File

@ -64,7 +64,12 @@ namespace NavisworksTransport
/// <summary>
/// 吊装路径 - 起点吊起 → 平移 → 终点吊下
/// </summary>
Hoisting = 2
Hoisting = 2,
/// <summary>
/// 自由路径 - 任意多点,每个路径点即物体包围盒中心,轨迹即物体中心
/// </summary>
Free = 3
}
/// <summary>
@ -85,6 +90,8 @@ namespace NavisworksTransport
return "空轨";
case PathType.Hoisting:
return "吊装";
case PathType.Free:
return "自由";
default:
return "未知";
}

View File

@ -1596,6 +1596,11 @@ namespace NavisworksTransport
// 地面路径:路径点是地面位置,通行空间底面在地面,中心需要向上偏移半个高度
verticalOffset = ResolveGroundPathObjectSpaceVerticalOffset(height);
}
else if (visualization.PathRoute.PathType == NavisworksTransport.PathType.Free)
{
// 自由路径:路径点即物体几何中心,通行空间以路径点为中心,无偏移
verticalOffset = 0;
}
else if (visualization.PathRoute.PathType == NavisworksTransport.PathType.Rail)
{
verticalOffset = RailPathPoseHelper.GetObjectSpaceCenterZOffset(

View File

@ -1383,6 +1383,7 @@ namespace NavisworksTransport.UI.WPF.ViewModels
public ICommand NewPathCommand { get; private set; }
public ICommand NewRailPathCommand { get; private set; }
public ICommand NewFreePathCommand { get; private set; }
public ICommand NewHoistingPathCommand { get; private set; }
public ICommand NewMultiLevelHoistingPathCommand { get; private set; }
public ICommand DuplicatePathCommand { get; private set; }
@ -1435,6 +1436,8 @@ namespace NavisworksTransport.UI.WPF.ViewModels
public bool CanExecuteNewRailPath => !IsSelectingStartPoint && !IsSelectingEndPoint;
public bool CanExecuteNewFreePath => !IsSelectingStartPoint && !IsSelectingEndPoint;
public bool CanExecuteStartEdit => SelectedPathRoute != null &&
(_pathPlanningManager?.PathEditState == PathEditState.Viewing);
@ -2012,6 +2015,7 @@ namespace NavisworksTransport.UI.WPF.ViewModels
{
NewPathCommand = new RelayCommand(async () => await ExecuteNewPathAsync(), () => CanExecuteNewPath);
NewRailPathCommand = new RelayCommand(async () => await ExecuteNewRailPathAsync(), () => CanExecuteNewRailPath);
NewFreePathCommand = new RelayCommand(async () => await ExecuteNewFreePathAsync(), () => CanExecuteNewFreePath);
NewHoistingPathCommand = new RelayCommand(async () => await ExecuteNewHoistingPathAsync());
NewMultiLevelHoistingPathCommand = new RelayCommand(async () => await ExecuteNewMultiLevelHoistingPathAsync());
SelectMultiLevelStartPointCommand = new RelayCommand(async () => await ExecuteSelectMultiLevelStartPointAsync());
@ -3724,6 +3728,81 @@ namespace NavisworksTransport.UI.WPF.ViewModels
}
/// <summary>
/// 执行新建自由路径命令
/// </summary>
private async Task ExecuteNewFreePathAsync()
{
await SafeExecuteAsync(() =>
{
UpdateMainStatus("正在创建自由路径...");
// 清除所有现有路径的可视化显示
if (PathPointRenderPlugin.Instance != null)
{
try
{
PathPointRenderPlugin.Instance.ClearPathsExcept("grid_visualization_all", "grid_visualization_channel", "grid_visualization_unknown", "grid_visualization_obstacle", "grid_visualization_door");
LogManager.Info("新建自由路径:已清除现有路径可视化显示(保留网格可视化)");
}
catch (Exception ex)
{
LogManager.Error($"新建自由路径:清除现有路径可视化失败: {ex.Message}", ex);
throw;
}
}
if (_pathPlanningManager != null)
{
var newRoute = _pathPlanningManager.StartCreatingNewRoute(
pathType: PathType.Free);
if (newRoute != null)
{
var newPathViewModel = new PathRouteViewModel
{
Route = newRoute,
IsActive = true,
PathType = PathType.Free
};
var pathPoints = CreatePathPointViewModelsFromCoreRoute(newRoute);
foreach (var point in pathPoints)
{
newPathViewModel.Points.Add(point);
}
PathRoutes.Add(newPathViewModel);
SelectedPathRoute = newPathViewModel;
if (!ForceReinitializeToolPlugin(subscribeToEvents: true))
{
UpdateMainStatus("ToolPlugin初始化失败,请重试");
LogManager.Error("新建自由路径:ToolPlugin初始化失败");
return;
}
// 启动点击工具(无吸附、无吊装模式),点击即添加物体中心轨迹点
_pathPlanningManager.StartClickTool(PathPointType.WayPoint);
LogManager.Info($"已启动自由路径点击工具: {newRoute.Name}");
UpdateMainStatus($"已进入自由路径模式: {newRoute.Name} - 请在3D视图中连续点击设置物体中心轨迹点最后设为终点完成");
LogManager.Info($"开始新建自由路径: {newRoute.Name}");
}
else
{
UpdateMainStatus("创建自由路径失败");
LogManager.Error("创建自由路径失败");
}
}
else
{
UpdateMainStatus("路径规划管理器未初始化");
LogManager.Error("路径规划管理器未初始化");
}
}, "新建自由路径");
}
/// <summary>
/// 执行新建吊装路径命令
/// </summary>
private async Task ExecuteNewHoistingPathAsync()
@ -4920,6 +4999,7 @@ namespace NavisworksTransport.UI.WPF.ViewModels
// 通知按钮状态更新,禁用"手动创建"按钮
OnPropertyChanged(nameof(CanExecuteNewPath));
OnPropertyChanged(nameof(CanExecuteNewFreePath));
UpdateMainStatus("请在3D视图中点击选择起点...");
@ -4978,6 +5058,7 @@ namespace NavisworksTransport.UI.WPF.ViewModels
// 通知按钮状态更新,禁用"手动创建"按钮
OnPropertyChanged(nameof(CanExecuteNewPath));
OnPropertyChanged(nameof(CanExecuteNewFreePath));
UpdateMainStatus("请在3D视图中点击选择终点...");
@ -5194,6 +5275,7 @@ namespace NavisworksTransport.UI.WPF.ViewModels
// 通知Can Execute属性更改
OnPropertyChanged(nameof(CanExecuteAutoPlanPath));
OnPropertyChanged(nameof(CanExecuteNewPath));
OnPropertyChanged(nameof(CanExecuteNewFreePath));
LogManager.Info("自动路径规划参数和起终点标记已完全重置,ToolPlugin已重新初始化");
}, "重置自动路径规划");
@ -6133,6 +6215,7 @@ namespace NavisworksTransport.UI.WPF.ViewModels
// 通知Can Execute属性更改
OnPropertyChanged(nameof(CanExecuteAutoPlanPath));
OnPropertyChanged(nameof(CanExecuteNewPath));
OnPropertyChanged(nameof(CanExecuteNewFreePath));
// 渲染新的起点标记
if (PathPointRenderPlugin.Instance != null)
@ -6184,6 +6267,7 @@ namespace NavisworksTransport.UI.WPF.ViewModels
// 通知Can Execute属性更改
OnPropertyChanged(nameof(CanExecuteAutoPlanPath));
OnPropertyChanged(nameof(CanExecuteNewPath));
OnPropertyChanged(nameof(CanExecuteNewFreePath));
// 渲染新的终点标记
if (PathPointRenderPlugin.Instance != null)
@ -6973,6 +7057,7 @@ namespace NavisworksTransport.UI.WPF.ViewModels
// 通知命令状态更新
OnPropertyChanged(nameof(CanExecuteNewPath));
OnPropertyChanged(nameof(CanExecuteNewFreePath));
OnPropertyChanged(nameof(CanExecuteStartEdit));
OnPropertyChanged(nameof(CanExecuteEndEdit));
OnPropertyChanged(nameof(CanExecuteModifyPoint));

View File

@ -215,6 +215,10 @@ NavisworksTransport 路径编辑页签视图 - 采用与动画控制和分层管
Command="{Binding NewRailPathCommand}"
Style="{StaticResource SecondaryButtonStyle}"
ToolTip="创建基于空轨基准线吸附的传统 Rail 路径"/>
<Button Content="自由"
Command="{Binding NewFreePathCommand}"
Style="{StaticResource SecondaryButtonStyle}"
ToolTip="创建自由路径(任意多点,路径点即物体几何中心)"/>
<Button Content="复制"
Command="{Binding DuplicatePathCommand}"
Style="{StaticResource SecondaryButtonStyle}"